diff --git a/packages/libs/error-stack/Cargo.toml b/packages/libs/error-stack/Cargo.toml index c6588cb1551..bec6c6b36a6 100644 --- a/packages/libs/error-stack/Cargo.toml +++ b/packages/libs/error-stack/Cargo.toml @@ -21,6 +21,7 @@ tracing-error = { version = "0.2", optional = true, default_features = false } anyhow = { version = "1", default-features = false, optional = true } eyre = { version = "0.6", default-features = false, optional = true } owo-colors = { version = "3", default-features = false, optional = true, features = ['supports-colors'] } +serde = { version = "1.0.147", default-features = false, optional = true } [dev-dependencies] serde = { version = "1.0.137", features = ["derive"] } @@ -29,7 +30,7 @@ futures = { version = "0.3.21", default-features = false, features = ["executor" trybuild = "1.0.63" tracing = "0.1.35" tracing-subscriber = "0.3.14" -insta = { version = "1.18.0", features = ['filters'] } +insta = { version = "1.18.0", features = ['filters', 'ron'] } regex = "1.6.0" expect-test = "1.4.0" ansi-to-html = "0.1.0" @@ -45,6 +46,7 @@ pretty-print = ["dep:owo-colors"] spantrace = ["dep:tracing-error", "std"] std = ["anyhow?/std"] eyre = ["dep:eyre", "std"] +serde = ["dep:serde"] [package.metadata.docs.rs] all-features = true diff --git a/packages/libs/error-stack/Makefile.toml b/packages/libs/error-stack/Makefile.toml index f9417030647..144a2a47a7a 100644 --- a/packages/libs/error-stack/Makefile.toml +++ b/packages/libs/error-stack/Makefile.toml @@ -70,13 +70,23 @@ run_task = { name = ['update-snapshots-task'] } [tasks.update-snapshots-task] private = true -run_task = { name = ['update-snapshots-task-lib', 'update-snapshots-task-doc'] } +run_task = { name = ['update-snapshots-task-lib-fmt', 'update-snapshots-task-lib-ser', 'update-snapshots-task-lib-no-default', 'update-snapshots-task-doc'] } -[tasks.update-snapshots-task-lib] +[tasks.update-snapshots-task-lib-fmt] extend = "cargo" args = ["hack", "@@split(CARGO_INSTA_TEST_HACK_FLAGS, )", "nextest", "run", "--cargo-profile", "${CARGO_MAKE_CARGO_PROFILE}", "@@split(CARGO_TEST_FLAGS, )", "@@split(CARGO_INSTA_TEST_FLAGS, )", "${@}"] env = { "INSTA_FORCE_PASS" = "1", "RUST_BACKTRACE" = "1", "INSTA_UPDATE" = "new" } +[tasks.update-snapshots-task-lib-ser] +extend = "cargo" +args = ["nextest", "run", "--cargo-profile", "${CARGO_MAKE_CARGO_PROFILE}", "@@split(CARGO_TEST_FLAGS, )", "--features", "std,spantrace,serde"] +env = { "INSTA_FORCE_PASS" = "1", "RUST_BACKTRACE" = "1", "INSTA_UPDATE" = "new" } + +[tasks.update-snapshots-task-lib-no-default] +extend = "cargo" +args = ["nextest", "run", "--cargo-profile", "${CARGO_MAKE_CARGO_PROFILE}", "@@split(CARGO_TEST_FLAGS, )", '--no-default-features'] +env = { "INSTA_FORCE_PASS" = "1", "RUST_BACKTRACE" = "1", "INSTA_UPDATE" = "new" } + [tasks.update-snapshots-task-doc] # only run on nightly, as backtraces are otherwise not included condition = { channels = ["nightly"] } diff --git a/packages/libs/error-stack/src/lib.rs b/packages/libs/error-stack/src/lib.rs index c1d05af28cd..79bc11c67a8 100644 --- a/packages/libs/error-stack/src/lib.rs +++ b/packages/libs/error-stack/src/lib.rs @@ -481,6 +481,8 @@ pub mod fmt; mod fmt; #[cfg(feature = "std")] mod hook; +#[cfg(feature = "serde")] +mod serde; #[cfg(feature = "std")] #[allow(deprecated, unreachable_pub)] diff --git a/packages/libs/error-stack/src/serde.rs b/packages/libs/error-stack/src/serde.rs new file mode 100644 index 00000000000..bb2d5ec4e07 --- /dev/null +++ b/packages/libs/error-stack/src/serde.rs @@ -0,0 +1,153 @@ +//! Implementation of general [`Report`] serialization. +//! +//! The value can be of any type, currently only printable attachments and context are supported, in +//! the near future any values will be supported through the use of hooks. +//! +//! The serialized [`Report`] is a list of all current sources with the following output: +//! +//! ```json +//! { +//! "context": "context display output", +//! "attachments": ["all", "attachments", "leading", "up", "to", "this", "context"], +//! "sources": [] // recursive render using `frame.sources()` +//! } +//! ``` + +use alloc::{format, vec, vec::Vec}; + +use serde::{ser::SerializeMap, Serialize, Serializer}; + +use crate::{AttachmentKind, Context, Frame, FrameKind, Report}; + +struct SerializeAttachment<'a>(&'a Frame); + +impl<'a> Serialize for SerializeAttachment<'a> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let Self(frame) = self; + + #[allow(clippy::match_same_arms)] + match frame.kind() { + FrameKind::Context(_) => { + // TODO: for now `Context` is unsupported, upcoming PR will fix via hooks + // `SerializeAttachmentList` ensures that no context is ever serialized + todo!() + } + FrameKind::Attachment(AttachmentKind::Opaque(_)) => { + // TODO: for now opaque attachments are unsupported, upcoming PR will fix that + // `SerializeAttachmentList` ensures that no such attachment is added + todo!() + } + FrameKind::Attachment(AttachmentKind::Printable(attachment)) => { + format!("{attachment}").serialize(serializer) + } + } + } +} + +struct SerializeAttachmentList<'a, 'b>(&'a [&'b Frame]); + +impl<'a, 'b> Serialize for SerializeAttachmentList<'a, 'b> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_seq( + self.0 + .iter() + .copied() + .filter(|attachment| { + // for now opaque attachments are ignored + !matches!( + attachment.kind(), + FrameKind::Attachment(AttachmentKind::Opaque(_)) + ) + }) + .map(SerializeAttachment), + ) + } +} + +struct SerializeContext<'a> { + attachments: Vec<&'a Frame>, + context: &'a dyn Context, + sources: &'a [Frame], +} + +impl<'a> Serialize for SerializeContext<'a> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let Self { + context, + attachments, + sources, + } = self; + + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("context", &format!("{context}"))?; + map.serialize_entry("attachments", &SerializeAttachmentList(&attachments[..]))?; + map.serialize_entry("sources", &SerializeSources(sources))?; + + map.end() + } +} + +struct SerializeSources<'a>(&'a [Frame]); + +impl<'a> Serialize for SerializeSources<'a> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_seq(self.0.iter().flat_map(|source| find_next(&[], source))) + } +} + +// find the next applicable context and return the serializer +fn find_next<'a>(head: &[&'a Frame], mut current: &'a Frame) -> Vec> { + let mut attachments = vec![]; + attachments.extend(head); + + loop { + if let FrameKind::Context(context) = current.kind() { + // found the context, return all attachments (reversed) + attachments.reverse(); + + return vec![SerializeContext { + attachments, + context, + sources: current.sources(), + }]; + } else if current.sources().len() > 1 { + // current is an attachment, add to attachments and recursively probe + attachments.push(current); + + return current + .sources() + .iter() + .flat_map(|source| find_next(&attachments, source)) + .collect(); + } else if current.sources().len() == 1 { + attachments.push(current); + + current = ¤t.sources()[0]; + } else { + // there are no more frames, therefore we need to abandon + // this is theoretically impossible (the bottom is always a context), but not enforced + return vec![]; + } + } +} + +impl Serialize for Report { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + SerializeSources(self.current_frames()).serialize(serializer) + } +} diff --git a/packages/libs/error-stack/tests/common.rs b/packages/libs/error-stack/tests/common.rs index b21a7b760c3..7e8df54eef0 100644 --- a/packages/libs/error-stack/tests/common.rs +++ b/packages/libs/error-stack/tests/common.rs @@ -152,6 +152,16 @@ impl fmt::Display for PrintableB { } } +#[derive(Debug, PartialEq, Eq)] +pub struct PrintableC(pub u32); + +impl fmt::Display for PrintableC { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("printable C: ")?; + fmt::Display::fmt(&self.0, fmt) + } +} + pub fn create_error() -> Result<(), RootError> { Err(create_report()) } diff --git a/packages/libs/error-stack/tests/snapshots/test_serialize__attachment.snap b/packages/libs/error-stack/tests/snapshots/test_serialize__attachment.snap new file mode 100644 index 00000000000..7ac05a2a78e --- /dev/null +++ b/packages/libs/error-stack/tests/snapshots/test_serialize__attachment.snap @@ -0,0 +1,13 @@ +--- +source: tests/test_serialize.rs +expression: report +--- +[ + { + "context": "root error", + "attachments": [ + "printable A", + ], + "sources": [], + }, +] diff --git a/packages/libs/error-stack/tests/snapshots/test_serialize__context.snap b/packages/libs/error-stack/tests/snapshots/test_serialize__context.snap new file mode 100644 index 00000000000..82d16414c23 --- /dev/null +++ b/packages/libs/error-stack/tests/snapshots/test_serialize__context.snap @@ -0,0 +1,19 @@ +--- +source: tests/test_serialize.rs +expression: report +--- +[ + { + "context": "context A", + "attachments": [], + "sources": [ + { + "context": "root error", + "attachments": [ + "printable A", + ], + "sources": [], + }, + ], + }, +] diff --git a/packages/libs/error-stack/tests/snapshots/test_serialize__multiple_sources.snap b/packages/libs/error-stack/tests/snapshots/test_serialize__multiple_sources.snap new file mode 100644 index 00000000000..4ca67eb9ed0 --- /dev/null +++ b/packages/libs/error-stack/tests/snapshots/test_serialize__multiple_sources.snap @@ -0,0 +1,30 @@ +--- +source: tests/test_serialize.rs +expression: a +--- +[ + { + "context": "context A", + "attachments": [ + "printable C: 4", + ], + "sources": [ + { + "context": "root error", + "attachments": [ + "printable C: 1", + "printable C: 3", + ], + "sources": [], + }, + { + "context": "root error", + "attachments": [ + "printable C: 2", + "printable C: 3", + ], + "sources": [], + }, + ], + }, +] diff --git a/packages/libs/error-stack/tests/snapshots/test_serialize__multiple_sources_at_root.snap b/packages/libs/error-stack/tests/snapshots/test_serialize__multiple_sources_at_root.snap new file mode 100644 index 00000000000..e993e0f4724 --- /dev/null +++ b/packages/libs/error-stack/tests/snapshots/test_serialize__multiple_sources_at_root.snap @@ -0,0 +1,20 @@ +--- +source: tests/test_serialize.rs +expression: a +--- +[ + { + "context": "root error", + "attachments": [ + "printable C: 1", + ], + "sources": [], + }, + { + "context": "root error", + "attachments": [ + "printable C: 2", + ], + "sources": [], + }, +] diff --git a/packages/libs/error-stack/tests/test_serialize.rs b/packages/libs/error-stack/tests/test_serialize.rs new file mode 100644 index 00000000000..96e50ee4ab7 --- /dev/null +++ b/packages/libs/error-stack/tests/test_serialize.rs @@ -0,0 +1,71 @@ +//! Note: span_trace, backtrace and such are not special cased, therefore all tests run with all +//! tests enabled. +#![cfg(all(feature = "std", feature = "spantrace", feature = "serde"))] +// can be considered safe, because we only check the output, which in itself does not use **any** +// unsafe code. +#![cfg(not(miri))] +#![cfg_attr(all(nightly, feature = "std"), feature(error_generic_member_access))] +#![cfg_attr(nightly, feature(provide_any))] + +use insta::assert_ron_snapshot; + +use crate::common::{create_report, ContextA, PrintableA, PrintableC}; + +mod common; + +fn prepare() -> impl Drop { + std::env::set_var("RUST_LIB_BACKTRACE", "0"); + + let settings = insta::Settings::clone_current(); + + settings.bind_to_scope() +} + +#[test] +fn attachment() { + let _guard = prepare(); + + let report = create_report().attach_printable(PrintableA(2)); + + assert_ron_snapshot!(report); +} + +#[test] +fn context() { + let _guard = prepare(); + + let report = create_report() + .attach_printable(PrintableA(2)) + .change_context(ContextA(2)); + + assert_ron_snapshot!(report); +} + +#[test] +fn multiple_sources() { + let _guard = prepare(); + + let mut a = create_report().attach_printable(PrintableC(1)); + let b = create_report().attach_printable(PrintableC(2)); + + a.extend_one(b); + + let a = a + .attach_printable(PrintableC(3)) + .change_context(ContextA(2)) + .attach_printable(PrintableC(4)); + + assert_ron_snapshot!(a); +} + +#[test] +fn multiple_sources_at_root() { + let _guard = prepare(); + + let mut a = create_report().attach_printable(PrintableC(1)); + let b = create_report().attach_printable(PrintableC(2)); + + a.extend_one(b); + + assert_ron_snapshot!(a); +}