Skip to main content

errlanes/
fail.rs

1use std::{error::Error, fmt};
2
3use crate::profile::{AllLanes, LaneProfile, NarrowDenied, NarrowTransient};
4
5use crate::lane::{Denied, Fatal, Lane, Transient};
6
7/// Operator level, independent of any tracing dependency in the core API.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
9pub enum Level {
10    Trace,
11    Debug,
12    Info,
13    Warn,
14    Error,
15}
16
17impl Level {
18    /// The upper-case name written to the `error.level` span field.
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Level::Trace => "TRACE",
22            Level::Debug => "DEBUG",
23            Level::Info => "INFO",
24            Level::Warn => "WARN",
25            Level::Error => "ERROR",
26        }
27    }
28}
29
30#[cfg(feature = "tracing")]
31impl From<Level> for tracing::Level {
32    fn from(level: Level) -> Self {
33        match level {
34            Level::Trace => tracing::Level::TRACE,
35            Level::Debug => tracing::Level::DEBUG,
36            Level::Info => tracing::Level::INFO,
37            Level::Warn => tracing::Level::WARN,
38            Level::Error => tracing::Level::ERROR,
39        }
40    }
41}
42
43/// One code a rejection family can resolve to, with its operator-facing text.
44///
45/// ```
46/// use errlanes::{CodeInfo, Rejection, RejectionCode};
47///
48/// fn describe<R: Rejection>() -> Vec<String> {
49///     <R::Code as RejectionCode>::CODES
50///         .iter()
51///         .map(|CodeInfo { code, description }| {
52///             format!("{code}: {}", description.unwrap_or("(undocumented)"))
53///         })
54///         .collect()
55/// }
56/// # fn main() {}
57/// ```
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub struct CodeInfo {
60    /// The wire identity, exactly what `Into<&'static str>` on the `Code` yields.
61    pub code: &'static str,
62    /// From `#[rejection(description = "..")]`, else the first paragraph of
63    /// the leaf's `///` doc comment, else `None`. Never the `#[error("..")]`
64    /// literal: that is a `Display` template, not prose.
65    pub description: Option<&'static str>,
66}
67
68/// The code type of a rejection family: a closed, enumerable catalogue.
69///
70/// ```
71/// use errlanes::RejectionCode;
72///
73/// /// The thing is missing.
74/// #[derive(errlanes::Rejection, Debug)]
75/// #[rejection(code = "NOT_FOUND")]
76/// #[error("the thing is missing")]
77/// struct NotFound;
78///
79/// for entry in <NotFoundCode as RejectionCode>::CODES {
80///     assert_eq!(entry.code, "NOT_FOUND");
81///     assert_eq!(entry.description, Some("The thing is missing."));
82/// }
83/// ```
84pub trait RejectionCode:
85    Copy + Eq + std::hash::Hash + fmt::Debug + fmt::Display + Into<&'static str> + Send + Sync + 'static
86{
87    /// Every code a value of this type can resolve to — complete through
88    /// `delegate`, `code_and_level_from`, and `#[lift(..)]`-forwarded
89    /// variants; no duplicates (a diamond keeps its first occurrence);
90    /// declaration order, depth-first.
91    const CODES: &'static [CodeInfo];
92}
93
94/// Const helpers the derive uses to build a deduplicated catalogue.
95#[doc(hidden)]
96pub mod __catalogue {
97    use super::CodeInfo;
98
99    const fn str_eq(a: &str, b: &str) -> bool {
100        let (a, b) = (a.as_bytes(), b.as_bytes());
101        if a.len() != b.len() {
102            return false;
103        }
104        let mut i = 0;
105        while i < a.len() {
106            if a[i] != b[i] {
107                return false;
108            }
109            i += 1;
110        }
111        true
112    }
113
114    /// Whether `code` is already among the first `taken` distinct entries
115    /// found by walking `parts` in order (used to implement first-wins).
116    const fn seen_before(parts: &[&[CodeInfo]], part: usize, idx: usize) -> bool {
117        let code = parts[part][idx].code;
118        let mut p = 0;
119        while p <= part {
120            let end = if p == part { idx } else { parts[p].len() };
121            let mut i = 0;
122            while i < end {
123                if str_eq(parts[p][i].code, code) {
124                    return true;
125                }
126                i += 1;
127            }
128            p += 1;
129        }
130        false
131    }
132
133    /// Count of distinct `code`s across `parts`, first occurrence wins.
134    pub const fn catalogue_len(parts: &[&[CodeInfo]]) -> usize {
135        let mut n = 0;
136        let mut p = 0;
137        while p < parts.len() {
138            let mut i = 0;
139            while i < parts[p].len() {
140                if !seen_before(parts, p, i) {
141                    n += 1;
142                }
143                i += 1;
144            }
145            p += 1;
146        }
147        n
148    }
149
150    /// The distinct entries of `parts` in declaration order. `N` must equal
151    /// [`catalogue_len`]; a mismatch is a const-eval (compile) error.
152    pub const fn catalogue<const N: usize>(parts: &[&[CodeInfo]]) -> [CodeInfo; N] {
153        assert!(catalogue_len(parts) == N, "catalogue length mismatch");
154        let mut out = [CodeInfo {
155            code: "",
156            description: None,
157        }; N];
158        let mut n = 0;
159        let mut p = 0;
160        while p < parts.len() {
161            let mut i = 0;
162            while i < parts[p].len() {
163                if !seen_before(parts, p, i) {
164                    out[n] = parts[p][i];
165                    n += 1;
166                }
167                i += 1;
168            }
169            p += 1;
170        }
171        out
172    }
173
174    /// The `code` of every entry, same order.
175    pub const fn codes_of<const N: usize>(entries: &[CodeInfo]) -> [&'static str; N] {
176        assert!(entries.len() == N, "catalogue length mismatch");
177        let mut out = [""; N];
178        let mut i = 0;
179        while i < N {
180            out[i] = entries[i].code;
181            i += 1;
182        }
183        out
184    }
185}
186
187/// A pure, caller-correctable domain outcome. Implemented by hand or via
188/// `#[derive(errlanes::Rejection)]`, which emits `Display`/`Error` itself
189/// (defaulting `Display` to the code) unless `#[rejection(error = manual)]`
190/// hands that to `thiserror` or a hand-written impl.
191pub trait Rejection: Error + Send + Sync + 'static {
192    /// A stable, typed, wire-safe identity for this outcome.
193    type Code: RejectionCode;
194
195    fn code(&self) -> Self::Code;
196
197    /// Operator level for this outcome. Rejections default to `Warn`.
198    fn level(&self) -> Level {
199        Level::Warn
200    }
201}
202
203/// Consuming mapping. `#[derive(Lift)]` with `#[lift(Source)]` generates an exhaustive
204/// mapping with `Unmapped = Infallible` and a total `From<Source>` conversion.
205/// `#[lift(Source, unhandled = fatal)]` returns the original unmapped source;
206/// [`Fail::lift`] wraps it as a fatal invariant with its source intact.
207/// The derive does not require or implement [`Rejection`]. Derive `Rejection`
208/// separately to provide codes and levels; simple lift mappings forward that
209/// metadata by default unless the destination declares its own.
210pub trait Lift<X>: Sized {
211    type Unmapped;
212    fn lift(x: X) -> Result<Self, Self::Unmapped>;
213}
214
215/// A total `From` counts as a strict lift, so one call-site method can require
216/// only `Lift` and still cover both mapping modes. Bounded to `X: Rejection`
217/// (rather than any `X`) so it does not overlap the `Lift<Infallible>`
218/// blanket below — both are needed, since a [`crate::Classify`] wrapper's
219/// rejected slot is either a `Rejection` or `Infallible` (never anything
220/// else, by `RejectedSlot`'s own two impls).
221impl<X: Rejection, P: From<X>> Lift<X> for P {
222    type Unmapped = core::convert::Infallible;
223    fn lift(x: X) -> Result<Self, Self::Unmapped> {
224        Ok(P::from(x))
225    }
226}
227
228/// Every destination lifts an `Infallible` rejected slot trivially — this is
229/// what lets a fault-only [`crate::Classify`] wrapper satisfy the `Fail`
230/// blanket for *any* `D`, with no `Lift`/`From` declared at all.
231impl<P> Lift<core::convert::Infallible> for P {
232    type Unmapped = core::convert::Infallible;
233    fn lift(x: core::convert::Infallible) -> Result<Self, Self::Unmapped> {
234        match x {}
235    }
236}
237
238/// Conversion of an unmapped value into an enabled fatal slot. Strict lifts
239/// have no unmapped values and therefore work with any destination profile.
240#[doc(hidden)]
241pub trait UnmappedInto<F> {
242    fn unmapped_into(self) -> F;
243}
244impl<F> UnmappedInto<F> for core::convert::Infallible {
245    fn unmapped_into(self) -> F {
246        match self {}
247    }
248}
249impl<R: Rejection> UnmappedInto<Fatal> for R {
250    fn unmapped_into(self) -> Fatal {
251        // `with_opaque_source`: `self` is a `Rejection`, whose `Display` may
252        // embed caller-supplied input (see the trait's display discipline);
253        // `source()` still returns it for a handler or test to
254        // `downcast_ref`, but `message_chain` must not walk into it.
255        Fatal::from_error(crate::FatalKind::Invariant, self)
256            .with_context("unhandled rejection at partial lift")
257            .with_opaque_source()
258    }
259}
260
261/// `Fail` minus the `Rejected` lane: what an operation that cannot reject
262/// (nothing about it is the caller's to correct) returns. Reads return
263/// `Fault<L>`; writes return `Fail<{Entity}ConstraintViolation, L>` — the type
264/// itself says whether a call can ever hand back a domain outcome.
265#[derive(Debug, Clone)]
266pub enum Fault<L: LaneProfile = AllLanes> {
267    Denied(L::Denied),
268    Transient(L::Transient),
269    Fatal(L::Fatal),
270}
271
272impl<L: LaneProfile> Fault<L> {
273    pub fn lane(&self) -> Lane {
274        match self {
275            Fault::Denied(_) => Lane::Denied,
276            Fault::Transient(_) => Lane::Transient,
277            Fault::Fatal(_) => Lane::Fatal,
278        }
279    }
280
281    pub fn is_transient(&self) -> bool {
282        matches!(self, Fault::Transient(_))
283    }
284
285    pub fn is_fatal(&self) -> bool {
286        matches!(self, Fault::Fatal(_))
287    }
288
289    pub fn is_denied(&self) -> bool {
290        matches!(self, Fault::Denied(_))
291    }
292
293    /// The operator-safe one-line text for this failure — exactly what
294    /// `record` writes to `exception.message`, for a boundary that must
295    /// persist it rather than (or as well as) record it. `Transient`/
296    /// `Fatal`: the whole `source()` chain joined with `": "`. `Denied`: its
297    /// `Display`.
298    pub fn message(&self) -> String {
299        self.lanes().message()
300    }
301
302    /// Consumes the `Transient` lane, yielding the same profile with its
303    /// transient slot disabled. `attempts` is what the retry loop counted; an
304    /// exhausted transient becomes `Fatal(Exhausted)` with the last transient
305    /// as its source.
306    pub fn narrow_transient(self, attempts: u32) -> Fault<crate::profile::WithoutTransient<L>>
307    where
308        L::Transient: NarrowTransient<L::Fatal>,
309    {
310        match self {
311            Fault::Denied(d) => Fault::Denied(d),
312            Fault::Transient(last) => Fault::Fatal(last.narrow(attempts)),
313            Fault::Fatal(f) => Fault::Fatal(f),
314        }
315    }
316
317    /// Narrows away the `Denied` lane: a denial at a boundary with no
318    /// subject (code running as the system) becomes `Fatal(Denied)` with
319    /// the `Denied` as its source.
320    pub fn narrow_denied(self) -> Fault<crate::profile::WithoutDenied<L>>
321    where
322        L::Denied: NarrowDenied<L::Fatal>,
323    {
324        match self {
325            Fault::Denied(d) => Fault::Fatal(d.narrow()),
326            Fault::Transient(t) => Fault::Transient(t),
327            Fault::Fatal(f) => Fault::Fatal(f),
328        }
329    }
330}
331
332/// Borrowed lane accessors. `min_exhaustive_patterns` lets a *by-value* match
333/// name only the lanes a profile enables, but a borrowed match still demands an
334/// arm for every variant. These accessors are the borrowed form, so no caller
335/// ever has to write a `match *never {}` arm. Each is available only when the
336/// profile enables that lane, so `e.as_denied()` on a no-denial profile is a
337/// compile error rather than a permanent `None`.
338impl<L: LaneProfile<Denied = Denied>> Fault<L> {
339    pub fn as_denied(&self) -> Option<&Denied> {
340        match self {
341            Fault::Denied(d) => Some(d),
342            _ => None,
343        }
344    }
345}
346
347impl<L: LaneProfile<Transient = Transient>> Fault<L> {
348    pub fn as_transient(&self) -> Option<&Transient> {
349        match self {
350            Fault::Transient(t) => Some(t),
351            _ => None,
352        }
353    }
354
355    pub fn is_congestion(&self) -> bool {
356        self.as_transient().is_some_and(Transient::is_congestion)
357    }
358
359    /// See [`crate::TransientKind::is_contention`].
360    pub fn is_contention(&self) -> bool {
361        self.as_transient().is_some_and(Transient::is_contention)
362    }
363}
364
365impl<L: LaneProfile<Fatal = Fatal>> Fault<L> {
366    pub fn as_fatal(&self) -> Option<&Fatal> {
367        match self {
368            Fault::Fatal(f) => Some(f),
369            _ => None,
370        }
371    }
372}
373
374impl<D, L: LaneProfile<Denied = Denied>> Fail<D, L> {
375    pub fn as_denied(&self) -> Option<&Denied> {
376        match self {
377            Fail::Denied(d) => Some(d),
378            _ => None,
379        }
380    }
381}
382
383impl<D, L: LaneProfile<Transient = Transient>> Fail<D, L> {
384    pub fn as_transient(&self) -> Option<&Transient> {
385        match self {
386            Fail::Transient(t) => Some(t),
387            _ => None,
388        }
389    }
390
391    pub fn is_congestion(&self) -> bool {
392        self.as_transient().is_some_and(Transient::is_congestion)
393    }
394
395    /// See [`crate::TransientKind::is_contention`].
396    pub fn is_contention(&self) -> bool {
397        self.as_transient().is_some_and(Transient::is_contention)
398    }
399}
400
401impl<D, L: LaneProfile<Fatal = Fatal>> Fail<D, L> {
402    pub fn as_fatal(&self) -> Option<&Fatal> {
403        match self {
404            Fail::Fatal(f) => Some(f),
405            _ => None,
406        }
407    }
408}
409
410impl<L: LaneProfile> fmt::Display for Fault<L> {
411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412        match self {
413            Fault::Denied(d) => write!(f, "{d}"),
414            Fault::Transient(t) => write!(f, "{t}"),
415            Fault::Fatal(x) => write!(f, "{x}"),
416        }
417    }
418}
419
420impl<L: LaneProfile> Error for Fault<L> {
421    fn source(&self) -> Option<&(dyn Error + 'static)> {
422        // Same contract as `Fail::source` (pitfall 2): the lane payload
423        // itself, so `lane_of` can downcast it.
424        match self {
425            Fault::Denied(d) => Some(d),
426            Fault::Transient(t) => Some(t),
427            Fault::Fatal(x) => Some(x),
428        }
429    }
430}
431
432/// The generic view over a domain rejection `D`, before retries have run.
433///
434/// **Display discipline**: [`Display`](fmt::Display)'s `Rejected` arm keeps
435/// its `rejected: {d}` prefix for logs, but no boundary may build a
436/// user-facing message from `to_string()` — a rejection's message may embed
437/// caller-supplied input. Use [`as_rejected`](Fail::as_rejected) and
438/// [`Rejection::code`] instead: `record_fail` keys `error.code` off the code,
439/// never the message, and a GraphQL boundary should do the same for its error
440/// extension.
441#[derive(Debug, Clone)]
442pub enum Fail<D, L: LaneProfile = AllLanes> {
443    Rejected(D),
444    Denied(L::Denied),
445    Transient(L::Transient),
446    Fatal(L::Fatal),
447}
448
449impl<D, L: LaneProfile> Fail<D, L> {
450    pub fn lane(&self) -> Lane {
451        match self {
452            Fail::Rejected(_) => Lane::Rejected,
453            Fail::Denied(_) => Lane::Denied,
454            Fail::Transient(_) => Lane::Transient,
455            Fail::Fatal(_) => Lane::Fatal,
456        }
457    }
458
459    /// Lift the rejection and, when requested, expand the lane profile without
460    /// changing any fault classification. The value-level form of
461    /// [`ResultExt::lift`](crate::ResultExt::lift), with
462    /// the same single rule: `P: Lift<D>` is satisfied by a total `From<D>`
463    /// (through errlanes' blanket, `Unmapped = Infallible`) and by a partial
464    /// `#[lift(Source, unhandled = fatal)]` mapping (`Unmapped = Source`),
465    /// whose unmapped cases demote to `Fatal(Invariant)` with the rejection
466    /// as their source — which is why a partial mapping requires the
467    /// destination to admit `Fatal`. The strict/partial choice is declared
468    /// once on the destination enum; the call site does not repeat it.
469    pub fn lift<P: Lift<D>, M: LaneProfile>(self) -> Fail<P, M>
470    where
471        L::Denied: Into<M::Denied>,
472        L::Transient: Into<M::Transient>,
473        L::Fatal: Into<M::Fatal>,
474        P::Unmapped: UnmappedInto<M::Fatal>,
475    {
476        match self {
477            Self::Rejected(d) => match P::lift(d) {
478                Ok(mapped) => Fail::Rejected(mapped),
479                Err(unmapped) => Fail::Fatal(unmapped.unmapped_into()),
480            },
481            Self::Denied(d) => Fail::Denied(d.into()),
482            Self::Transient(t) => Fail::Transient(t.into()),
483            Self::Fatal(f) => Fail::Fatal(f.into()),
484        }
485    }
486
487    pub fn lift_with<P, M: LaneProfile<Fatal = Fatal>>(
488        self,
489        f: impl FnOnce(D) -> Result<P, Fatal>,
490    ) -> Fail<P, M>
491    where
492        L::Denied: Into<M::Denied>,
493        L::Transient: Into<M::Transient>,
494        L::Fatal: Into<M::Fatal>,
495    {
496        match self {
497            Self::Rejected(d) => match f(d) {
498                Ok(p) => Fail::Rejected(p),
499                Err(e) => Fail::Fatal(e),
500            },
501            Self::Denied(d) => Fail::Denied(d.into()),
502            Self::Transient(t) => Fail::Transient(t.into()),
503            Self::Fatal(f) => Fail::Fatal(f.into()),
504        }
505    }
506
507    pub fn map_rejected<D2>(self, f: impl FnOnce(D) -> D2) -> Fail<D2, L> {
508        match self {
509            Fail::Rejected(d) => Fail::Rejected(f(d)),
510            Fail::Denied(d) => Fail::Denied(d),
511            Fail::Transient(t) => Fail::Transient(t),
512            Fail::Fatal(fatal) => Fail::Fatal(fatal),
513        }
514    }
515
516    /// Narrows to the domain outcome, or the non-domain fault. `let d =
517    /// e.rejected()?;` propagates the fault into any enclosing `Fail<_, L>` (or
518    /// a [`Carrier`](crate::Carrier)) via the blanket `From<Fault<L>>`.
519    pub fn rejected(self) -> Result<D, Fault<L>> {
520        match self {
521            Fail::Rejected(d) => Ok(d),
522            Fail::Denied(d) => Err(Fault::Denied(d)),
523            Fail::Transient(t) => Err(Fault::Transient(t)),
524            Fail::Fatal(f) => Err(Fault::Fatal(f)),
525        }
526    }
527
528    pub fn as_rejected(&self) -> Option<&D> {
529        match self {
530            Fail::Rejected(d) => Some(d),
531            _ => None,
532        }
533    }
534
535    pub fn is_transient(&self) -> bool {
536        matches!(self, Fail::Transient(_))
537    }
538
539    pub fn is_fatal(&self) -> bool {
540        matches!(self, Fail::Fatal(_))
541    }
542
543    pub fn is_denied(&self) -> bool {
544        matches!(self, Fail::Denied(_))
545    }
546
547    /// The operator-safe one-line text for this failure — exactly what
548    /// `record` writes to `exception.message`, for a boundary that must
549    /// persist it rather than (or as well as) record it. `Transient`/
550    /// `Fatal`: the whole `source()` chain joined with `": "`. `Denied`: its
551    /// `Display`. `Rejected`: the rejection's `code`, never its message
552    /// (display discipline — a rejection's message may embed caller-supplied
553    /// input).
554    pub fn message(&self) -> String
555    where
556        D: Rejection,
557    {
558        self.lanes().message()
559    }
560
561    /// Consumes the `Transient` lane, yielding the same rejection over `L`
562    /// with its transient slot disabled. An exhausted transient becomes
563    /// `Fatal(Exhausted)`, carrying `attempts` and the last transient as its
564    /// source.
565    pub fn narrow_transient(self, attempts: u32) -> Fail<D, crate::profile::WithoutTransient<L>>
566    where
567        L::Transient: NarrowTransient<L::Fatal>,
568    {
569        match self {
570            Fail::Rejected(d) => Fail::Rejected(d),
571            Fail::Denied(d) => Fail::Denied(d),
572            Fail::Transient(last) => Fail::Fatal(last.narrow(attempts)),
573            Fail::Fatal(f) => Fail::Fatal(f),
574        }
575    }
576
577    /// Narrows away the `Denied` lane: a denial at a boundary with no
578    /// subject (code running as the system) becomes `Fatal(Denied)` with
579    /// the `Denied` as its source.
580    pub fn narrow_denied(self) -> Fail<D, crate::profile::WithoutDenied<L>>
581    where
582        L::Denied: NarrowDenied<L::Fatal>,
583    {
584        match self {
585            Fail::Rejected(d) => Fail::Rejected(d),
586            Fail::Denied(d) => Fail::Fatal(d.narrow()),
587            Fail::Transient(t) => Fail::Transient(t),
588            Fail::Fatal(f) => Fail::Fatal(f),
589        }
590    }
591
592    /// Narrows away the `Rejected` lane: a rejection at a boundary with no
593    /// caller to correct it is an invariant violation, and becomes
594    /// `Fatal(Invariant)` with the rejection as its source — the same rule
595    /// a partial `#[lift(.., unhandled = fatal)]` applies. Nothing is left
596    /// to reject, so the result is a `Fault`. `with_opaque_source`: `d`'s
597    /// `Display` may embed caller-supplied input (the same display
598    /// discipline `Rejection` documents everywhere else), so `source()`
599    /// still returns it for a handler or test to `downcast_ref`, but
600    /// `message_chain` must not walk into it.
601    pub fn narrow_rejected(self) -> Fault<L>
602    where
603        D: Rejection,
604        L: LaneProfile<Fatal = Fatal>,
605    {
606        match self {
607            Fail::Rejected(d) => Fault::Fatal(invariant_from_rejection(d)),
608            Fail::Denied(d) => Fault::Denied(d),
609            Fail::Transient(t) => Fault::Transient(t),
610            Fail::Fatal(f) => Fault::Fatal(f),
611        }
612    }
613}
614
615/// The one construction site for "a rejection with no caller left to correct
616/// it": `Fatal(Invariant)` carrying the rejection as its source. Shared by
617/// [`Fail::narrow_rejected`] and the bare-rejection `narrow_rejected` in
618/// `result_ext`, so both apply the same context and the same
619/// `with_opaque_source` discipline (`d`'s `Display` may embed caller input).
620pub(crate) fn invariant_from_rejection<D: Rejection>(d: D) -> Fatal {
621    Fatal::from_error(crate::FatalKind::Invariant, d)
622        .with_context("rejected with no caller to correct")
623        .with_opaque_source()
624}
625
626impl<D: fmt::Display, L: LaneProfile> fmt::Display for Fail<D, L> {
627    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
628        match self {
629            Fail::Rejected(d) => write!(f, "rejected: {d}"),
630            Fail::Denied(d) => write!(f, "{d}"),
631            Fail::Transient(t) => write!(f, "{t}"),
632            Fail::Fatal(x) => write!(f, "{x}"),
633        }
634    }
635}
636
637impl<D: Error + 'static, L: LaneProfile> Error for Fail<D, L> {
638    fn source(&self) -> Option<&(dyn Error + 'static)> {
639        match self {
640            // The lane payload itself must be the *next* link in the chain
641            // so that `lane_of` can find it by downcasting.
642            Fail::Rejected(d) => Some(d),
643            Fail::Denied(d) => Some(d),
644            Fail::Transient(t) => Some(t),
645            Fail::Fatal(x) => Some(x),
646        }
647    }
648}
649
650pub(crate) mod sealed {
651    pub trait Sealed {}
652    impl<D: super::Rejection, L: super::LaneProfile> Sealed for super::Fail<D, L> {}
653    impl<L: super::LaneProfile> Sealed for super::Fault<L> {}
654    impl<C: crate::Carrier> Sealed for C {}
655}
656
657/// Sealed. The thing `retry`/`record` need from any error they are handed:
658/// its lane, and how to narrow it. Implemented by [`Fail<D, L>`], by
659/// [`Fault<L>`] — the two shapes a generated repo op can return — and by every
660/// [`Carrier`](crate::Carrier) whose built-in is `Laned`.
661pub trait Laned: sealed::Sealed + Error + Send + Sync + 'static + Sized {
662    type WithoutTransient: Error + Send + Sync + 'static;
663
664    fn lane(&self) -> Lane;
665
666    /// The operator level of this failure: the lane's default
667    /// ([`Lane::level`]), overridden only by a `Rejected` outcome's
668    /// [`Rejection::level`].
669    fn level(&self) -> Level;
670
671    fn is_transient(&self) -> bool {
672        self.lane() == Lane::Transient
673    }
674
675    fn is_fatal(&self) -> bool {
676        self.lane() == Lane::Fatal
677    }
678
679    fn is_denied(&self) -> bool {
680        self.lane() == Lane::Denied
681    }
682
683    fn narrow_transient(self, attempts: u32) -> Self::WithoutTransient;
684
685    /// The operator-safe one-line text for this failure — exactly what
686    /// `record` writes to `exception.message`, for a boundary that must
687    /// persist it rather than (or as well as) record it. `Transient`/
688    /// `Fatal`: the whole `source()` chain joined with `": "`. `Denied`: its
689    /// `Display`. `Rejected`: the rejection's `code`, never its message
690    /// (display discipline — a rejection's message may embed caller-supplied
691    /// input).
692    fn message(&self) -> String;
693
694    #[cfg(feature = "tracing")]
695    fn record(&self, span: &tracing::Span);
696
697    /// Backs [`crate::emit`].
698    #[cfg(feature = "tracing")]
699    #[doc(hidden)]
700    fn emit_event(&self);
701}
702
703/// Note the `NarrowTransient` bound: a profile that admits `Transient` but
704/// not `Fatal` is not `Laned`, so `retry` cannot be handed one. Retrying an
705/// operation that claims it can never fail permanently is exactly the
706/// contradiction the bound rules out.
707impl<D: Rejection, L: LaneProfile> Laned for Fail<D, L>
708where
709    L::Transient: NarrowTransient<L::Fatal>,
710{
711    type WithoutTransient = Fail<D, crate::profile::WithoutTransient<L>>;
712
713    fn lane(&self) -> Lane {
714        Fail::lane(self)
715    }
716
717    fn level(&self) -> Level {
718        self.lanes().level()
719    }
720
721    fn narrow_transient(self, attempts: u32) -> Self::WithoutTransient {
722        Fail::narrow_transient(self, attempts)
723    }
724
725    fn message(&self) -> String {
726        Fail::message(self)
727    }
728
729    #[cfg(feature = "tracing")]
730    fn record(&self, span: &tracing::Span) {
731        self.lanes().record(span);
732    }
733
734    #[cfg(feature = "tracing")]
735    fn emit_event(&self) {
736        self.lanes().emit();
737    }
738}
739
740/// A carrier is `Laned` exactly when its built-in is, and narrows through it.
741/// Disjoint from the two built-in impls above: `Fault` / `Fail` are not
742/// carriers, and downstream cannot implement `Carrier` for them (orphan rule).
743impl<C: crate::Carrier> Laned for C
744where
745    C::Repr: Laned,
746    <C as crate::IntoLanes>::Rejected: crate::carrier::LaneRejected,
747{
748    type WithoutTransient = <C::Repr as Laned>::WithoutTransient;
749
750    fn lane(&self) -> Lane {
751        crate::Carrier::lanes(self).lane()
752    }
753
754    fn level(&self) -> Level {
755        crate::Carrier::lanes(self).level()
756    }
757
758    fn narrow_transient(self, attempts: u32) -> Self::WithoutTransient {
759        self.into_repr().narrow_transient(attempts)
760    }
761
762    fn message(&self) -> String {
763        crate::Carrier::lanes(self).message()
764    }
765
766    #[cfg(feature = "tracing")]
767    fn record(&self, span: &tracing::Span) {
768        crate::Carrier::lanes(self).record(span);
769    }
770
771    #[cfg(feature = "tracing")]
772    fn emit_event(&self) {
773        crate::Carrier::lanes(self).emit();
774    }
775}
776
777impl<L: LaneProfile> Laned for Fault<L>
778where
779    L::Transient: NarrowTransient<L::Fatal>,
780{
781    type WithoutTransient = Fault<crate::profile::WithoutTransient<L>>;
782
783    fn lane(&self) -> Lane {
784        Fault::lane(self)
785    }
786
787    fn level(&self) -> Level {
788        self.lanes().level()
789    }
790
791    fn narrow_transient(self, attempts: u32) -> Self::WithoutTransient {
792        Fault::narrow_transient(self, attempts)
793    }
794
795    fn message(&self) -> String {
796        Fault::message(self)
797    }
798
799    #[cfg(feature = "tracing")]
800    fn record(&self, span: &tracing::Span) {
801        self.lanes().record(span);
802    }
803
804    #[cfg(feature = "tracing")]
805    fn emit_event(&self) {
806        self.lanes().emit();
807    }
808}
809
810/// Compile-time field metadata used by the rejection composition protocol.
811#[doc(hidden)]
812pub trait RejectionField<const VARIANT: u64, const FIELD: usize> {
813    type Type;
814}
815
816/// Borrowed metadata protocol; forwarding does not clone or reconstruct errors.
817#[doc(hidden)]
818pub trait RejectionMetadata<const VARIANT: u64>: Rejection {
819    type Fields<'a>;
820    /// The codes this one variant can resolve to: its own leaf, or whatever
821    /// it forwards (`delegate`, `code_and_level_from`, `#[lift(..)]`).
822    const CODES: &'static [CodeInfo];
823    fn field_code(fields: Self::Fields<'_>) -> Self::Code;
824    fn field_level(fields: Self::Fields<'_>) -> Level;
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use crate::lane::{Exhausted, FatalKind, TransientKind};
831
832    #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
833    struct SmallCode;
834    impl fmt::Display for SmallCode {
835        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
836            f.write_str("SMALL")
837        }
838    }
839    impl From<SmallCode> for &'static str {
840        fn from(_: SmallCode) -> Self {
841            "SMALL"
842        }
843    }
844    impl RejectionCode for SmallCode {
845        const CODES: &'static [CodeInfo] = &[CodeInfo {
846            code: "SMALL",
847            description: None,
848        }];
849    }
850
851    #[derive(Debug, Clone, PartialEq, Eq)]
852    struct Small;
853    impl fmt::Display for Small {
854        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
855            f.write_str("small")
856        }
857    }
858    impl Error for Small {}
859    impl Rejection for Small {
860        type Code = SmallCode;
861        fn code(&self) -> SmallCode {
862            SmallCode
863        }
864    }
865
866    #[derive(Debug, Clone, PartialEq, Eq)]
867    struct Big(Small);
868    impl fmt::Display for Big {
869        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
870            write!(f, "big({})", self.0)
871        }
872    }
873    impl Error for Big {}
874    impl From<Small> for Big {
875        fn from(s: Small) -> Self {
876            Big(s)
877        }
878    }
879    impl Rejection for Big {
880        type Code = SmallCode;
881        fn code(&self) -> SmallCode {
882            self.0.code()
883        }
884    }
885
886    #[test]
887    fn expand_preserves_the_lane() {
888        let f: Fail<Small> = Fail::Rejected(Small);
889        let converted: Fail<Big> = f.lift();
890        assert_eq!(converted.lane(), Lane::Rejected);
891        assert_eq!(converted.as_rejected(), Some(&Big(Small)));
892
893        let t: Fail<Small> = Transient::new(TransientKind::Deadlock).into();
894        let converted: Fail<Big> = t.lift();
895        assert_eq!(converted.lane(), Lane::Transient);
896    }
897
898    #[test]
899    fn expand_with_demotes_an_unmapped_rejection_to_fatal() {
900        let f: Fail<Small> = Fail::Rejected(Small);
901        let converted: Fail<Big> = f.lift_with(|_| Err(Fatal::invariant("unmapped")));
902        assert_eq!(converted.lane(), Lane::Fatal);
903
904        let f: Fail<Small> = Fail::Rejected(Small);
905        let converted: Fail<Big> = f.lift_with(|s| Ok(Big(s)));
906        assert_eq!(converted.lane(), Lane::Rejected);
907    }
908
909    /// Regression: `UnmappedInto<Fatal> for R: Rejection` (the partial-lift
910    /// path) must keep the unmapped rejection reachable by `downcast_ref`
911    /// for a handler or test, but never let its `Display` -- which may embed
912    /// caller-supplied input -- reach an operator-facing message.
913    #[test]
914    fn unmapped_rejection_demoted_to_fatal_does_not_leak_its_display() {
915        let fatal: Fatal = Small.unmapped_into();
916        assert_eq!(fatal.kind, FatalKind::Invariant);
917        assert!(
918            fatal.source().unwrap().downcast_ref::<Small>().is_some(),
919            "the rejection must still be reachable for a handler or test to downcast"
920        );
921        let message = crate::dynamic::message_chain(&fatal);
922        assert!(
923            !message.contains("small"),
924            "a partial lift's unmapped rejection's Display must never reach an \
925             operator-facing message; got {message:?}"
926        );
927    }
928
929    /// Same regression as `unmapped_rejection_demoted_to_fatal_does_not_leak_its_display`,
930    /// for `narrow_rejected`'s own construction site.
931    #[test]
932    fn narrow_rejected_does_not_leak_the_rejections_display() {
933        let f: Fail<Small> = Fail::Rejected(Small);
934        let narrowed = f.narrow_rejected();
935        match &narrowed {
936            Fault::Fatal(fatal) => {
937                assert_eq!(fatal.kind, FatalKind::Invariant);
938                assert!(fatal.source().unwrap().downcast_ref::<Small>().is_some());
939            }
940            other => panic!("expected Fatal(Invariant), got {other:?}"),
941        }
942        let message = narrowed.message();
943        assert!(
944            !message.contains("small"),
945            "narrow_rejected's rejection Display must never reach an operator-facing \
946             message; got {message:?}"
947        );
948    }
949
950    #[test]
951    fn narrow_transient_turns_transient_into_exhausted_and_leaves_everything_else_alone() {
952        let t: Fail<Small> = Transient::new(TransientKind::Deadlock).into();
953        match t.narrow_transient(3) {
954            Fail::Fatal(f) => {
955                assert_eq!(f.kind, FatalKind::Exhausted);
956                let e = Error::source(&f)
957                    .and_then(|s| s.downcast_ref::<Exhausted>())
958                    .expect("exhaustion is the fatal's source");
959                assert_eq!(e.attempts, 3);
960                assert_eq!(e.last.kind, TransientKind::Deadlock);
961            }
962            other => panic!("expected Fatal(Exhausted), got {other:?}"),
963        }
964
965        let r: Fail<Small> = Fail::Rejected(Small);
966        assert!(matches!(r.narrow_transient(1), Fail::Rejected(Small)));
967
968        let fatal: Fail<Small> = Fatal::new(FatalKind::Config).into();
969        assert!(matches!(fatal.narrow_transient(1), Fail::Fatal(_)));
970    }
971
972    #[test]
973    fn fault_converts_into_any_fail_via_the_real_blanket() {
974        let fault: Fault = Fatal::new(FatalKind::CorruptState).into();
975        let converted: Fail<Small> = fault.into();
976        assert_eq!(converted.lane(), Lane::Fatal);
977    }
978
979    #[test]
980    fn rejected_narrows_or_returns_the_fault() {
981        let f: Fail<Small> = Fail::Rejected(Small);
982        assert_eq!(f.rejected().unwrap(), Small);
983
984        let t: Fail<Small> = Transient::new(TransientKind::Deadlock).into();
985        assert!(t.rejected().is_err());
986    }
987
988    #[test]
989    fn fail_source_is_the_lane_payload_itself_so_lane_of_can_downcast_it() {
990        let t: Fail<Small> = Transient::new(TransientKind::Deadlock).into();
991        let source = std::error::Error::source(&t).expect("source present");
992        assert!(source.is::<Transient>());
993    }
994
995    #[test]
996    fn fault_source_is_the_lane_payload_itself_so_lane_of_can_downcast_it() {
997        let t: Fault = Transient::new(TransientKind::Deadlock).into();
998        let source = std::error::Error::source(&t).expect("source present");
999        assert!(source.is::<Transient>());
1000    }
1001
1002    #[test]
1003    fn laned_narrow_transient_agrees_between_fail_and_fault() {
1004        fn exhausted_attempts(e: &(dyn Error + 'static)) -> u32 {
1005            e.source()
1006                .and_then(|s| s.downcast_ref::<Exhausted>())
1007                .expect("exhaustion is the fatal's source")
1008                .attempts
1009        }
1010
1011        let t: Fail<Small> = Transient::new(TransientKind::Deadlock).into();
1012        let narrowed: Fail<Small, crate::profile::WithoutTransient<AllLanes>> =
1013            Laned::narrow_transient(t, 2);
1014        assert_eq!(narrowed.lane(), Lane::Fatal);
1015        assert_eq!(exhausted_attempts(narrowed.as_fatal().unwrap()), 2);
1016
1017        let t: Fault = Transient::new(TransientKind::Deadlock).into();
1018        let narrowed: Fault<crate::profile::WithoutTransient<AllLanes>> =
1019            Laned::narrow_transient(t, 2);
1020        assert_eq!(narrowed.lane(), Lane::Fatal);
1021        assert_eq!(exhausted_attempts(narrowed.as_fatal().unwrap()), 2);
1022    }
1023}