diff --git a/Cargo.lock b/Cargo.lock
index d24d0a9c9ed..6edee4d4bb1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10864,6 +10864,8 @@ dependencies = [
name = "problematic"
version = "0.0.0"
dependencies = [
+ "erased-serde",
+ "error-stack",
"http 1.4.2",
"insta",
"schemars 1.2.1",
diff --git a/Cargo.toml b/Cargo.toml
index 5c7020db71b..5d2d8845cc5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -163,6 +163,7 @@ email_address = { version = "0.2.9", default-features = fal
ena = { version = "0.14.3", default-features = false }
enum-iterator = { version = "2.1.0", default-features = false }
enumflags2 = { version = "0.7.12", default-features = false }
+erased-serde = { version = "0.4.10", default-features = false }
expect-test = { version = "1.5.1", default-features = false }
figment = { version = "0.10.19", default-features = false }
foldhash = { version = "0.2.0", default-features = false }
diff --git a/libs/problematic/rust/Cargo.toml b/libs/problematic/rust/Cargo.toml
index f1c8f048506..8436a30c56b 100644
--- a/libs/problematic/rust/Cargo.toml
+++ b/libs/problematic/rust/Cargo.toml
@@ -11,6 +11,7 @@ exclude = ["package.json", "turbo.json", "docs/task-dependencies.json"
[dependencies]
# Public workspace dependencies
+error-stack = { workspace = true, public = true, optional = true }
# Public third-party dependencies
http = { workspace = true, public = true }
@@ -22,16 +23,25 @@ serde_core = { workspace = true, public = true, optional = true }
# Private workspace dependencies
# Private third-party dependencies
-serde = { workspace = true, optional = true, features = ["alloc", "derive"] }
+erased-serde = { workspace = true, optional = true, features = ["alloc"] }
+serde = { workspace = true, optional = true, features = ["alloc", "derive"] }
[dev-dependencies]
insta = { workspace = true, features = ["json"] }
serde_json = { workspace = true, features = ["arbitrary_precision"] }
+[[test]]
+name = "error_stack"
+required-features = ["error-stack"]
+
[[test]]
name = "extensions"
required-features = ["serde"]
+[[test]]
+name = "problem"
+required-features = ["serde"]
+
[[test]]
name = "problem_details"
required-features = ["serde"]
@@ -41,8 +51,9 @@ name = "schema"
required-features = ["schemars"]
[features]
-schemars = ["dep:schemars"]
-serde = ["dep:serde_core", "dep:serde"]
+error-stack = ["dep:error-stack", "dep:erased-serde", "serde"]
+schemars = ["dep:schemars"]
+serde = ["dep:serde_core", "dep:serde"]
[lints]
workspace = true
diff --git a/libs/problematic/rust/README.md b/libs/problematic/rust/README.md
index bac9a0b80ef..330a6ca37ee 100644
--- a/libs/problematic/rust/README.md
+++ b/libs/problematic/rust/README.md
@@ -6,6 +6,7 @@ HTTP Problem Details with typed extension members.
- `serde` enables serialization and deserialization, including borrowing strings from the input.
- `schemars` enables JSON Schema generation independently of `serde`.
+- `error-stack` enables attaching problems to reports and includes `serde`.
No features are enabled by default.
diff --git a/libs/problematic/rust/docs/task-dependencies.json b/libs/problematic/rust/docs/task-dependencies.json
index df0316a8f2c..6854544ee37 100644
--- a/libs/problematic/rust/docs/task-dependencies.json
+++ b/libs/problematic/rust/docs/task-dependencies.json
@@ -1,6 +1,8 @@
{
"package": "@rust/problematic",
- "dependencies": [],
+ "dependencies": [
+ "@rust/error-stack"
+ ],
"tasks": {
"lint:clippy": {
"dependsOn": [],
diff --git a/libs/problematic/rust/package.json b/libs/problematic/rust/package.json
index 718c1913083..ae2020910ee 100644
--- a/libs/problematic/rust/package.json
+++ b/libs/problematic/rust/package.json
@@ -7,5 +7,8 @@
"scripts": {
"lint:clippy": "just clippy",
"test:unit": "mise run test:unit @rust/problematic"
+ },
+ "dependencies": {
+ "@rust/error-stack": "workspace:*"
}
}
diff --git a/libs/problematic/rust/src/error_stack.rs b/libs/problematic/rust/src/error_stack.rs
new file mode 100644
index 00000000000..136d7602976
--- /dev/null
+++ b/libs/problematic/rust/src/error_stack.rs
@@ -0,0 +1,200 @@
+//! Public problem details attached to [`Report`] values.
+
+use alloc::boxed::Box;
+
+use error_stack::{IntoReport, Report};
+use serde_core::Serialize;
+
+use crate::{Problem, ProblemDetails};
+
+/// Attaches and retrieves public problems independently of a report's current context.
+pub trait ReportExt: Sized {
+ /// Attaches the problem to expose to the client.
+ ///
+ /// The attachment survives [`Report::change_context`]. Attaching another problem replaces the
+ /// complete public representation returned by [`Self::problem_details`]; earlier problems
+ /// remain in the report's frames.
+ ///
+ /// The report owns the problem. Its details can borrow from it and are serialized only when the
+ /// returned [`ProblemDetails`] is serialized.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use std::{borrow::Cow, fmt, io};
+ ///
+ /// use error_stack::Report;
+ /// use problematic::{ProblemType, StatusCode, error_stack::ReportExt as _};
+ ///
+ /// const INVALID_PARAMETER: ProblemType = ProblemType {
+ /// type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"),
+ /// title: Cow::Borrowed("Invalid parameter"),
+ /// status: StatusCode::BAD_REQUEST,
+ /// };
+ ///
+ /// let report = Report::new(fmt::Error)
+ /// .attach_problem(INVALID_PARAMETER.detail("The limit must be positive."))
+ /// .change_context(io::Error::other("request failed"));
+ /// let details = report
+ /// .problem_details()
+ /// .expect("the problem should be attached");
+ ///
+ /// assert_eq!(details.status, 400);
+ /// assert_eq!(
+ /// details.detail.as_deref(),
+ /// Some("The limit must be positive.")
+ /// );
+ /// ```
+ #[must_use]
+ fn attach_problem
(self, problem: P) -> Self
+ where
+ P: Problem + Send + Sync + 'static,
+ for<'a> P::Extensions<'a>: Serialize;
+
+ /// Returns the attached problem's details, or [`None`] if no problem was attached.
+ ///
+ /// Selects the first problem in [`Report::frames`] order. For a single chain of contexts, this
+ /// is the most recently attached problem. For reports combined with [`Report::append`], the
+ /// frame traversal order determines which branch supplies the problem.
+ ///
+ /// The details borrow from the report. Extension validation runs when they are serialized and
+ /// returns a serializer error for invalid extensions.
+ #[must_use]
+ fn problem_details(&self) -> Option>;
+}
+
+impl ReportExt for Report {
+ #[track_caller]
+ fn attach_problem(self, problem: P) -> Self
+ where
+ P: Problem + Send + Sync + 'static,
+ for<'a> P::Extensions<'a>: Serialize,
+ {
+ self.attach_opaque(AttachedProblem(Box::new(problem)))
+ }
+
+ fn problem_details(&self) -> Option> {
+ self.downcast_ref::()
+ .map(|problem| problem.0.details())
+ }
+}
+
+/// Attaches public problems to the error variant of a [`Result`].
+pub trait ResultExt: Sized {
+ /// The context type of the resulting [`Report`].
+ type Context: ?Sized;
+
+ /// The successful value carried by the result.
+ type Ok;
+
+ /// Attaches `problem` to the error, converting it to a [`Report`] if needed.
+ ///
+ /// Uses [`ReportExt::attach_problem`] for errors and preserves successful values.
+ ///
+ /// # Errors
+ ///
+ /// Returns the original error as a [`Report`] with the problem attached.
+ #[track_caller]
+ fn attach_problem(self, problem: P) -> Result>
+ where
+ P: Problem + Send + Sync + 'static,
+ for<'a> P::Extensions<'a>: Serialize,
+ {
+ self.attach_problem_with(|| problem)
+ }
+
+ /// Creates and attaches a problem only when the result is [`Err`].
+ ///
+ /// Calls `problem` once on error, then uses [`ReportExt::attach_problem`]. On [`Ok`], the
+ /// closure is dropped without being called.
+ ///
+ /// # Errors
+ ///
+ /// Returns the original error as a [`Report`] with the created problem attached.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use std::borrow::Cow;
+ ///
+ /// use problematic::{
+ /// ProblemType, StatusCode,
+ /// error_stack::{ReportExt as _, ResultExt as _},
+ /// };
+ ///
+ /// const INVALID_PARAMETER: ProblemType = ProblemType {
+ /// type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"),
+ /// title: Cow::Borrowed("Invalid parameter"),
+ /// status: StatusCode::BAD_REQUEST,
+ /// };
+ ///
+ /// let input = "zero";
+ /// let result = input.parse::().attach_problem_with(|| {
+ /// INVALID_PARAMETER.detail(format!("The limit `{input}` is not an integer."))
+ /// });
+ /// let report = result.expect_err("the input should fail to parse");
+ /// let details = report
+ /// .problem_details()
+ /// .expect("the problem should be attached");
+ ///
+ /// assert_eq!(
+ /// details.detail.as_deref(),
+ /// Some("The limit `zero` is not an integer.")
+ /// );
+ /// ```
+ #[track_caller]
+ fn attach_problem_with(self, problem: F) -> Result>
+ where
+ P: Problem + Send + Sync + 'static,
+ for<'a> P::Extensions<'a>: Serialize,
+ F: FnOnce() -> P;
+}
+
+impl ResultExt for Result {
+ type Context = E::Context;
+ type Ok = T;
+
+ fn attach_problem_with(self, problem: F) -> Result>
+ where
+ P: Problem + Send + Sync + 'static,
+ for<'a> P::Extensions<'a>: Serialize,
+ F: FnOnce() -> P,
+ {
+ match self {
+ Ok(value) => Ok(value),
+ Err(error) => Err(error.into_report().attach_problem(problem())),
+ }
+ }
+}
+
+struct AttachedProblem(Box);
+
+trait ErasedProblem {
+ fn details(&self) -> ProblemDetails<'_, Box>;
+}
+
+impl ErasedProblem for P
+where
+ P: Problem + 'static,
+ for<'a> P::Extensions<'a>: Serialize,
+{
+ fn details(&self) -> ProblemDetails<'_, Box> {
+ let ProblemDetails {
+ type_uri,
+ title,
+ status,
+ detail,
+ instance,
+ extensions,
+ } = Problem::details(self);
+
+ ProblemDetails {
+ type_uri,
+ title,
+ status,
+ detail,
+ instance,
+ extensions: Box::new(extensions),
+ }
+ }
+}
diff --git a/libs/problematic/rust/src/lib.rs b/libs/problematic/rust/src/lib.rs
index cab2754e8bc..78a21b86f9b 100644
--- a/libs/problematic/rust/src/lib.rs
+++ b/libs/problematic/rust/src/lib.rs
@@ -2,15 +2,20 @@
//!
//! [`ProblemType`] describes a problem's shared metadata. [`ProblemDetails`] represents an
//! occurrence as a JSON object, with extension members alongside the standard fields.
+//! [`Problem`] lets a failure provide the details exposed to the client.
//!
//! The optional `serde` feature enables serialization and deserialization. The `schemars` feature
//! independently adds JSON Schema support to [`ProblemDetails`] and [`NoExtensions`].
+//! The `error-stack` feature enables attaching problems to reports and includes `serde`.
#![feature(const_convert, const_destruct, const_trait_impl)]
#![cfg_attr(doc, feature(doc_cfg))]
extern crate alloc;
+#[cfg(feature = "error-stack")]
+pub mod error_stack;
+mod problem;
mod problem_details;
mod problem_type;
#[cfg(feature = "serde")]
@@ -19,6 +24,7 @@ mod serde;
pub use http::StatusCode;
pub use self::{
+ problem::Problem,
problem_details::{NoExtensions, ProblemDetails},
problem_type::ProblemType,
};
diff --git a/libs/problematic/rust/src/problem.rs b/libs/problematic/rust/src/problem.rs
new file mode 100644
index 00000000000..b3e078e9a0b
--- /dev/null
+++ b/libs/problematic/rust/src/problem.rs
@@ -0,0 +1,145 @@
+use alloc::borrow::Cow;
+
+use crate::{NoExtensions, ProblemDetails, ProblemType};
+
+/// A failure that provides problem details for the client.
+///
+/// The details and extension members can borrow from the failure.
+///
+/// # Examples
+///
+/// ```
+/// use std::borrow::Cow;
+///
+/// use problematic::{Problem, ProblemDetails, ProblemType, StatusCode};
+///
+/// struct InvalidParameter {
+/// parameter: String,
+/// explanation: String,
+/// }
+///
+/// struct InvalidParameterExtensions<'a> {
+/// parameter: &'a str,
+/// }
+///
+/// const INVALID_PARAMETER: ProblemType = ProblemType {
+/// type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"),
+/// title: Cow::Borrowed("Invalid parameter"),
+/// status: StatusCode::BAD_REQUEST,
+/// };
+///
+/// impl Problem for InvalidParameter {
+/// type Extensions<'a> = InvalidParameterExtensions<'a>;
+///
+/// fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>> {
+/// INVALID_PARAMETER
+/// .detail(&self.explanation)
+/// .extensions(InvalidParameterExtensions {
+/// parameter: &self.parameter,
+/// })
+/// }
+/// }
+///
+/// let error = InvalidParameter {
+/// parameter: "limit".to_owned(),
+/// explanation: "The limit must be positive.".to_owned(),
+/// };
+/// let details = error.details();
+///
+/// assert_eq!(
+/// details.detail.as_deref(),
+/// Some("The limit must be positive.")
+/// );
+/// assert_eq!(details.extensions.parameter, "limit");
+/// ```
+pub trait Problem {
+ /// The extension members, which may borrow from this failure for `'a`.
+ type Extensions<'a>
+ where
+ Self: 'a;
+
+ /// Returns the problem details exposed to the client.
+ #[must_use]
+ fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>>;
+}
+
+impl Problem for ProblemType {
+ type Extensions<'a> = NoExtensions;
+
+ fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>> {
+ ProblemDetails::from(self)
+ }
+}
+
+impl Problem for ProblemDetails<'_, E> {
+ type Extensions<'a>
+ = &'a E
+ where
+ Self: 'a;
+
+ fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>> {
+ ProblemDetails {
+ type_uri: Cow::Borrowed(&self.type_uri),
+ title: Cow::Borrowed(&self.title),
+ status: self.status,
+ detail: self.detail.as_deref().map(Cow::Borrowed),
+ instance: self.instance.as_deref().map(Cow::Borrowed),
+ extensions: &self.extensions,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use alloc::{borrow::Cow, string::String};
+ use core::{assert_matches, ptr};
+
+ use crate::{Problem, ProblemDetails};
+
+ #[derive(Debug)]
+ struct Extensions<'a> {
+ parameter: &'a str,
+ }
+
+ fn details(problem: &P) -> ProblemDetails<'_, P::Extensions<'_>> {
+ problem.details()
+ }
+
+ #[test]
+ fn details_borrowed() {
+ let parameter = String::from("limit");
+ let source = ProblemDetails {
+ type_uri: Cow::Owned(String::from(
+ "https://example.com/problems/invalid-parameter",
+ )),
+ title: Cow::Owned(String::from("Invalid parameter")),
+ status: 400,
+ detail: Some(Cow::Owned(String::from("The limit must be positive."))),
+ instance: Some(Cow::Owned(String::from("/problem-occurrences/42"))),
+ extensions: Extensions {
+ parameter: ¶meter,
+ },
+ };
+ let borrowed = details(&source);
+
+ assert_matches!(
+ borrowed,
+ ProblemDetails {
+ type_uri: Cow::Borrowed(_),
+ title: Cow::Borrowed(_),
+ detail: Some(Cow::Borrowed(_)),
+ instance: Some(Cow::Borrowed(_)),
+ ..
+ },
+ "the details should borrow the source strings"
+ );
+ assert!(
+ ptr::eq(borrowed.extensions, &raw const source.extensions),
+ "the details should borrow the source extensions"
+ );
+ assert!(
+ ptr::eq(borrowed.extensions.parameter, parameter.as_str()),
+ "the extension member should retain its original borrow"
+ );
+ }
+}
diff --git a/libs/problematic/rust/src/problem_type.rs b/libs/problematic/rust/src/problem_type.rs
index 3e3965b4295..34bbf71588e 100644
--- a/libs/problematic/rust/src/problem_type.rs
+++ b/libs/problematic/rust/src/problem_type.rs
@@ -126,10 +126,10 @@ mod tests {
use alloc::{borrow::Cow, string::String};
use core::{assert_matches, ptr};
- use crate::{ProblemDetails, ProblemType, StatusCode};
+ use crate::{Problem as _, ProblemDetails, ProblemType, StatusCode};
#[test]
- fn from_owned_metadata() {
+ fn details_owned_metadata() {
let definition = ProblemType {
type_uri: Cow::Owned(String::from(
"https://example.com/problems/invalid-parameters",
@@ -137,15 +137,15 @@ mod tests {
title: Cow::Owned(String::from("Invalid parameters")),
status: StatusCode::BAD_REQUEST,
};
- let details = ProblemDetails::from(&definition);
-
- assert_matches!(
- details.type_uri, Cow::Borrowed(uri) if ptr::eq(uri, definition.type_uri.as_ref()),
- "the type URI should borrow the definition's allocation"
- );
- assert_matches!(
- details.title, Cow::Borrowed(title) if ptr::eq(title, definition.title.as_ref()),
- "the title should borrow the definition's allocation"
- );
+ for details in [ProblemDetails::from(&definition), definition.details()] {
+ assert_matches!(
+ details.type_uri, Cow::Borrowed(uri) if ptr::eq(uri, definition.type_uri.as_ref()),
+ "the type URI should borrow the definition's allocation"
+ );
+ assert_matches!(
+ details.title, Cow::Borrowed(title) if ptr::eq(title, definition.title.as_ref()),
+ "the title should borrow the definition's allocation"
+ );
+ }
}
}
diff --git a/libs/problematic/rust/tests/error_stack.rs b/libs/problematic/rust/tests/error_stack.rs
new file mode 100644
index 00000000000..9a403a0ed93
--- /dev/null
+++ b/libs/problematic/rust/tests/error_stack.rs
@@ -0,0 +1,332 @@
+extern crate alloc;
+
+use alloc::{borrow::Cow, string::String, sync::Arc};
+use core::{
+ assert_matches, fmt,
+ panic::Location,
+ sync::atomic::{AtomicUsize, Ordering},
+};
+use std::io;
+
+use error_stack::{Report, ResultExt as _};
+use problematic::{
+ Problem, ProblemDetails, ProblemType, StatusCode,
+ error_stack::{ReportExt as _, ResultExt as _},
+};
+use serde::{Serialize, Serializer, ser::Error as _};
+use serde_json::json;
+
+const INVALID_PARAMETER: ProblemType = ProblemType {
+ type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"),
+ title: Cow::Borrowed("Invalid parameter"),
+ status: StatusCode::BAD_REQUEST,
+};
+
+const UNAVAILABLE: ProblemType = ProblemType {
+ type_uri: Cow::Borrowed("https://example.com/problems/unavailable"),
+ title: Cow::Borrowed("Unavailable"),
+ status: StatusCode::SERVICE_UNAVAILABLE,
+};
+
+struct InvalidParameter {
+ parameter: String,
+ explanation: String,
+}
+
+#[derive(Serialize)]
+struct ParameterExtensions<'a> {
+ parameter: &'a str,
+}
+
+impl Problem for InvalidParameter {
+ type Extensions<'a> = ParameterExtensions<'a>;
+
+ fn details(&self) -> ProblemDetails<'_, Self::Extensions<'_>> {
+ INVALID_PARAMETER
+ .detail(&self.explanation)
+ .extensions(ParameterExtensions {
+ parameter: &self.parameter,
+ })
+ }
+}
+
+#[test]
+fn details_changed_context() {
+ let problem = InvalidParameter {
+ parameter: String::from("limit"),
+ explanation: String::from("The limit must be positive."),
+ };
+ let explanation = problem.explanation.as_ptr();
+ let report = Report::new(fmt::Error)
+ .attach_problem(problem)
+ .change_context(io::Error::other("request failed"))
+ .attach_opaque("diagnostic attachment")
+ .change_context(fmt::Error);
+
+ let details = report
+ .problem_details()
+ .expect("the problem should survive context changes");
+
+ assert_matches!(
+ details.detail,
+ Some(Cow::Borrowed(_)),
+ "the detail should borrow from the attached problem"
+ );
+ assert_eq!(
+ details.detail.as_deref().map(str::as_ptr),
+ Some(explanation),
+ "the detail should retain the attached problem's allocation"
+ );
+ assert_eq!(
+ serde_json::to_value(details).expect("the attached details should serialize"),
+ json!({
+ "type": "https://example.com/problems/invalid-parameter",
+ "title": "Invalid parameter",
+ "status": 400,
+ "detail": "The limit must be positive.",
+ "parameter": "limit"
+ }),
+ "the details and borrowed extensions should survive context changes"
+ );
+}
+
+#[test]
+fn details_latest_attachment() {
+ let report = Report::new(fmt::Error)
+ .attach_problem(InvalidParameter {
+ parameter: String::from("limit"),
+ explanation: String::from("The limit must be positive."),
+ })
+ .change_context(io::Error::other("request failed"))
+ .attach_problem(UNAVAILABLE)
+ .change_context(fmt::Error);
+
+ let details = report
+ .problem_details()
+ .expect("the most recent problem should be attached");
+
+ assert_eq!(
+ serde_json::to_value(details).expect("the attached details should serialize"),
+ json!({
+ "type": "https://example.com/problems/unavailable",
+ "title": "Unavailable",
+ "status": 503
+ }),
+ "the most recent problem should replace the complete public representation"
+ );
+}
+
+#[test]
+fn details_no_attachment() {
+ let report = Report::new(fmt::Error)
+ .attach_opaque("diagnostic attachment")
+ .change_context(io::Error::other("request failed"));
+
+ assert!(
+ report.problem_details().is_none(),
+ "a report without an attached problem should return no details"
+ );
+}
+
+#[test]
+fn details_combined_reports() {
+ let mut report = Report::new(fmt::Error)
+ .attach_problem(INVALID_PARAMETER.detail("The limit must be positive."))
+ .expand();
+ report.append(
+ Report::new(fmt::Error)
+ .attach_problem(UNAVAILABLE.instance("/problem-occurrences/42"))
+ .expand(),
+ );
+
+ assert_eq!(
+ report
+ .problem_details()
+ .expect("the first branch should contain a problem")
+ .status,
+ 400,
+ "the first problem in frame traversal order should take precedence"
+ );
+
+ let report = report.attach_problem(UNAVAILABLE.detail("Please retry later."));
+ assert_eq!(
+ report
+ .problem_details()
+ .expect("the combined report should contain the new problem")
+ .detail
+ .as_deref(),
+ Some("Please retry later."),
+ "a problem attached to the combined report should take precedence over both branches"
+ );
+}
+
+#[test]
+fn serialize_invalid_extensions() {
+ for (extensions, message) in [
+ (json!(42), "problem extensions must serialize as an object"),
+ (
+ json!({"status": 200}),
+ "problem extension `status` conflicts with a standard member",
+ ),
+ ] {
+ let report =
+ Report::new(fmt::Error).attach_problem(INVALID_PARAMETER.extensions(extensions));
+ let details = report
+ .problem_details()
+ .expect("the problem should be attached before serialization");
+ let error =
+ serde_json::to_value(details).expect_err("invalid extensions should fail to serialize");
+
+ assert_eq!(
+ error.to_string(),
+ message,
+ "extension validation should remain effective through type erasure"
+ );
+ }
+}
+
+struct FailingExtensions {
+ calls: Arc,
+}
+
+impl Serialize for FailingExtensions {
+ fn serialize(&self, _: S) -> Result {
+ self.calls.fetch_add(1, Ordering::Relaxed);
+ Err(S::Error::custom("extension serialization failed"))
+ }
+}
+
+#[test]
+fn serialize_extension_failure() {
+ let calls = Arc::new(AtomicUsize::new(0));
+ let report =
+ Report::new(fmt::Error).attach_problem(INVALID_PARAMETER.extensions(FailingExtensions {
+ calls: Arc::clone(&calls),
+ }));
+ let details = report
+ .problem_details()
+ .expect("the problem should be attached before serialization");
+
+ assert_eq!(
+ calls.load(Ordering::Relaxed),
+ 0,
+ "attaching and retrieving a problem should not serialize its extensions"
+ );
+
+ let error = serde_json::to_value(details)
+ .expect_err("the extension serializer failure should propagate");
+ assert_eq!(
+ error.to_string(),
+ "extension serialization failed",
+ "the caller should receive the original serializer error"
+ );
+ assert_eq!(
+ calls.load(Ordering::Relaxed),
+ 1,
+ "the extensions should be serialized once"
+ );
+}
+
+#[test]
+fn result_attach_ok() {
+ let result = Ok::<_, fmt::Error>(42)
+ .attach_problem(INVALID_PARAMETER.detail("The limit must be positive."));
+
+ assert_eq!(
+ result.expect("the result should remain successful"),
+ 42,
+ "attaching a problem should preserve the successful value"
+ );
+}
+
+#[test]
+fn result_attach_error() {
+ let result = Err::<(), _>(fmt::Error)
+ .attach_problem(INVALID_PARAMETER.detail("The limit must be positive."));
+ let report: Report = result.expect_err("the result should remain an error");
+
+ assert_eq!(
+ report
+ .problem_details()
+ .expect("the problem should be attached")
+ .detail
+ .as_deref(),
+ Some("The limit must be positive."),
+ "a plain error should become a report with the attached problem"
+ );
+ assert_eq!(
+ report
+ .downcast_ref::>()
+ .expect("the report should contain its creation location")
+ .file(),
+ file!(),
+ "the report should record the caller rather than the extension's implementation"
+ );
+}
+
+#[test]
+fn result_attach_existing_report() {
+ let report = Report::new(fmt::Error)
+ .attach_opaque(String::from("original diagnostic"))
+ .expand();
+ let result: Result<(), Report<[fmt::Error]>> =
+ Err(report).attach_problem(INVALID_PARAMETER.detail("The limit must be positive."));
+ let report = result.expect_err("the result should retain its report");
+
+ assert_eq!(
+ report.downcast_ref::().map(String::as_str),
+ Some("original diagnostic"),
+ "the existing report's frames should be preserved"
+ );
+ assert_eq!(
+ report
+ .problem_details()
+ .expect("the problem should be attached")
+ .status,
+ 400,
+ "the attachment should support reports with multiple contexts"
+ );
+}
+
+#[test]
+fn result_attach_with_ok() {
+ let mut calls = 0;
+ let result = Ok::<_, fmt::Error>(42).attach_problem_with(|| {
+ calls += 1;
+ INVALID_PARAMETER.detail("The limit must be positive.")
+ });
+
+ assert_eq!(
+ calls, 0,
+ "the problem factory should not run for a successful result"
+ );
+ assert_eq!(
+ result.expect("the result should remain successful"),
+ 42,
+ "lazy attachment should preserve the successful value"
+ );
+}
+
+#[test]
+fn result_attach_with_error() {
+ let mut calls = 0;
+ let explanation = String::from("The limit must be positive.");
+ let result = Err::<(), _>(fmt::Error)
+ .attach_problem_with(|| {
+ calls += 1;
+ INVALID_PARAMETER.detail(explanation)
+ })
+ .change_context(io::Error::other("request failed"));
+ let report = result.expect_err("the result should remain an error");
+
+ assert_eq!(calls, 1, "the problem factory should run once on error");
+ assert_eq!(
+ report
+ .problem_details()
+ .expect("the problem should survive the context change")
+ .detail
+ .as_deref(),
+ Some("The limit must be positive."),
+ "the factory should be able to move captured data into the problem"
+ );
+}
diff --git a/libs/problematic/rust/tests/problem.rs b/libs/problematic/rust/tests/problem.rs
new file mode 100644
index 00000000000..360873d3222
--- /dev/null
+++ b/libs/problematic/rust/tests/problem.rs
@@ -0,0 +1,48 @@
+extern crate alloc;
+
+use alloc::{borrow::Cow, string::String};
+
+use problematic::{Problem, ProblemType, StatusCode};
+use serde::Serialize;
+use serde_json::{Value, json};
+
+#[derive(Serialize)]
+struct Extensions<'a> {
+ parameter: &'a str,
+}
+
+fn serialize<'a, P: Problem + ?Sized>(problem: &'a P) -> Value
+where
+ P::Extensions<'a>: Serialize,
+{
+ serde_json::to_value(problem.details()).expect("the problem details should serialize")
+}
+
+#[test]
+fn details_borrowed_serialize() {
+ const INVALID_PARAMETER: ProblemType = ProblemType {
+ type_uri: Cow::Borrowed("https://example.com/problems/invalid-parameter"),
+ title: Cow::Borrowed("Invalid parameter"),
+ status: StatusCode::BAD_REQUEST,
+ };
+
+ let parameter = String::from("limit");
+ let explanation = String::from("The limit must be positive.");
+ let problem = INVALID_PARAMETER
+ .detail(&explanation)
+ .extensions(Extensions {
+ parameter: ¶meter,
+ });
+
+ assert_eq!(
+ serialize(&problem),
+ json!({
+ "type": "https://example.com/problems/invalid-parameter",
+ "title": "Invalid parameter",
+ "status": 400,
+ "detail": "The limit must be positive.",
+ "parameter": "limit"
+ }),
+ "the generic consumer should serialize the borrowed details and extension members"
+ );
+}
diff --git a/yarn.lock b/yarn.lock
index 7863fa6a9f4..315e16756d1 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -15580,6 +15580,8 @@ __metadata:
"@rust/problematic@workspace:libs/problematic/rust":
version: 0.0.0-use.local
resolution: "@rust/problematic@workspace:libs/problematic/rust"
+ dependencies:
+ "@rust/error-stack": "workspace:*"
languageName: unknown
linkType: soft