Skip to main content

toolkit_canonical_errors/
builder.rs

1use crate::context::{
2    Aborted, AlreadyExists, Cancelled, DataLoss, DeadlineExceeded, FailedPrecondition,
3    FieldViolation, Internal, InvalidArgument, NotFound, OutOfRange, PermissionDenied,
4    PreconditionViolation, QuotaViolation, ResourceExhausted, ServiceUnavailable, Unauthenticated,
5    Unimplemented, Unknown,
6};
7use crate::error::CanonicalError;
8
9// ---------------------------------------------------------------------------
10// Resource markers
11// ---------------------------------------------------------------------------
12
13#[doc(hidden)]
14pub struct ResourceAbsent;
15#[doc(hidden)]
16pub struct ResourceOptional;
17#[doc(hidden)]
18pub struct ResourceMissing;
19#[doc(hidden)]
20pub struct ResourceSet(String);
21
22// ---------------------------------------------------------------------------
23// Context markers
24// ---------------------------------------------------------------------------
25
26#[doc(hidden)]
27pub struct NoContext;
28#[doc(hidden)]
29pub struct NeedsFieldViolation;
30#[doc(hidden)]
31pub struct HasFieldViolations(Vec<FieldViolation>);
32#[doc(hidden)]
33pub struct NeedsPreconditionViolation;
34#[doc(hidden)]
35pub struct HasPreconditionViolations(Vec<PreconditionViolation>);
36#[doc(hidden)]
37pub struct NeedsQuotaViolation;
38#[doc(hidden)]
39pub struct HasQuotaViolations(Vec<QuotaViolation>);
40#[doc(hidden)]
41pub struct HasFormatMessage(String);
42#[doc(hidden)]
43pub struct HasConstraintMessage(String);
44#[doc(hidden)]
45pub struct NeedsReason;
46#[doc(hidden)]
47pub struct HasReason(String);
48
49// ---------------------------------------------------------------------------
50// Traits gating build()
51// ---------------------------------------------------------------------------
52
53#[doc(hidden)]
54pub trait ResourceResolved {
55    fn resolve(self) -> Option<String>;
56}
57
58impl ResourceResolved for ResourceAbsent {
59    fn resolve(self) -> Option<String> {
60        None
61    }
62}
63
64impl ResourceResolved for ResourceOptional {
65    fn resolve(self) -> Option<String> {
66        None
67    }
68}
69
70impl ResourceResolved for ResourceSet {
71    fn resolve(self) -> Option<String> {
72        Some(self.0)
73    }
74}
75
76#[doc(hidden)]
77pub struct ContextData {
78    pub field_violations: Vec<FieldViolation>,
79    pub precondition_violations: Vec<PreconditionViolation>,
80    pub quota_violations: Vec<QuotaViolation>,
81    pub format_message: Option<String>,
82    pub constraint_message: Option<String>,
83    pub reason: String,
84}
85
86#[doc(hidden)]
87pub trait ContextResolved {
88    fn into_context_data(self) -> ContextData;
89}
90
91impl ContextResolved for NoContext {
92    fn into_context_data(self) -> ContextData {
93        ContextData {
94            field_violations: Vec::new(),
95            precondition_violations: Vec::new(),
96            quota_violations: Vec::new(),
97            format_message: None,
98            constraint_message: None,
99            reason: String::new(),
100        }
101    }
102}
103
104impl ContextResolved for HasFieldViolations {
105    fn into_context_data(self) -> ContextData {
106        ContextData {
107            field_violations: self.0,
108            precondition_violations: Vec::new(),
109            quota_violations: Vec::new(),
110            format_message: None,
111            constraint_message: None,
112            reason: String::new(),
113        }
114    }
115}
116
117impl ContextResolved for HasFormatMessage {
118    fn into_context_data(self) -> ContextData {
119        ContextData {
120            field_violations: Vec::new(),
121            precondition_violations: Vec::new(),
122            quota_violations: Vec::new(),
123            format_message: Some(self.0),
124            constraint_message: None,
125            reason: String::new(),
126        }
127    }
128}
129
130impl ContextResolved for HasConstraintMessage {
131    fn into_context_data(self) -> ContextData {
132        ContextData {
133            field_violations: Vec::new(),
134            precondition_violations: Vec::new(),
135            quota_violations: Vec::new(),
136            format_message: None,
137            constraint_message: Some(self.0),
138            reason: String::new(),
139        }
140    }
141}
142
143impl ContextResolved for HasPreconditionViolations {
144    fn into_context_data(self) -> ContextData {
145        ContextData {
146            field_violations: Vec::new(),
147            precondition_violations: self.0,
148            quota_violations: Vec::new(),
149            format_message: None,
150            constraint_message: None,
151            reason: String::new(),
152        }
153    }
154}
155
156impl ContextResolved for HasQuotaViolations {
157    fn into_context_data(self) -> ContextData {
158        ContextData {
159            field_violations: Vec::new(),
160            precondition_violations: Vec::new(),
161            quota_violations: self.0,
162            format_message: None,
163            constraint_message: None,
164            reason: String::new(),
165        }
166    }
167}
168
169impl ContextResolved for HasReason {
170    fn into_context_data(self) -> ContextData {
171        ContextData {
172            field_violations: Vec::new(),
173            precondition_violations: Vec::new(),
174            quota_violations: Vec::new(),
175            format_message: None,
176            constraint_message: None,
177            reason: self.0,
178        }
179    }
180}
181
182// ---------------------------------------------------------------------------
183// Error variant discriminant
184// ---------------------------------------------------------------------------
185
186#[doc(hidden)]
187#[derive(Debug, Clone, Copy)]
188pub enum ErrorVariant {
189    Cancelled,
190    Unknown,
191    InvalidArgument,
192    DeadlineExceeded,
193    NotFound,
194    AlreadyExists,
195    PermissionDenied,
196    ResourceExhausted,
197    FailedPrecondition,
198    Aborted,
199    OutOfRange,
200    Unimplemented,
201    Internal,
202    DataLoss,
203    Unauthenticated,
204}
205
206// ---------------------------------------------------------------------------
207// ResourceErrorBuilder
208// ---------------------------------------------------------------------------
209
210pub struct ResourceErrorBuilder<Resource, Context> {
211    resource_type: Option<&'static str>,
212    detail: String,
213    variant: ErrorVariant,
214    resource: Resource,
215    context: Context,
216}
217
218// ---------------------------------------------------------------------------
219// #[doc(hidden)] constructors — called by the macro
220// ---------------------------------------------------------------------------
221
222impl ResourceErrorBuilder<ResourceMissing, NoContext> {
223    #[doc(hidden)]
224    pub fn __not_found(resource_type: &'static str, detail: impl Into<String>) -> Self {
225        ResourceErrorBuilder {
226            resource_type: Some(resource_type),
227            detail: detail.into(),
228            variant: ErrorVariant::NotFound,
229            resource: ResourceMissing,
230            context: NoContext,
231        }
232    }
233
234    #[doc(hidden)]
235    pub fn __already_exists(resource_type: &'static str, detail: impl Into<String>) -> Self {
236        ResourceErrorBuilder {
237            resource_type: Some(resource_type),
238            detail: detail.into(),
239            variant: ErrorVariant::AlreadyExists,
240            resource: ResourceMissing,
241            context: NoContext,
242        }
243    }
244
245    #[doc(hidden)]
246    pub fn __data_loss(resource_type: &'static str, detail: impl Into<String>) -> Self {
247        ResourceErrorBuilder {
248            resource_type: Some(resource_type),
249            detail: detail.into(),
250            variant: ErrorVariant::DataLoss,
251            resource: ResourceMissing,
252            context: NoContext,
253        }
254    }
255}
256
257impl ResourceErrorBuilder<ResourceOptional, NeedsReason> {
258    #[doc(hidden)]
259    pub fn __aborted(resource_type: &'static str, detail: impl Into<String>) -> Self {
260        ResourceErrorBuilder {
261            resource_type: Some(resource_type),
262            detail: detail.into(),
263            variant: ErrorVariant::Aborted,
264            resource: ResourceOptional,
265            context: NeedsReason,
266        }
267    }
268}
269
270impl ResourceErrorBuilder<ResourceOptional, NoContext> {
271    #[doc(hidden)]
272    pub fn __unknown(resource_type: &'static str, detail: impl Into<String>) -> Self {
273        ResourceErrorBuilder {
274            resource_type: Some(resource_type),
275            detail: detail.into(),
276            variant: ErrorVariant::Unknown,
277            resource: ResourceOptional,
278            context: NoContext,
279        }
280    }
281
282    #[doc(hidden)]
283    pub fn __deadline_exceeded(resource_type: &'static str, detail: impl Into<String>) -> Self {
284        ResourceErrorBuilder {
285            resource_type: Some(resource_type),
286            detail: detail.into(),
287            variant: ErrorVariant::DeadlineExceeded,
288            resource: ResourceOptional,
289            context: NoContext,
290        }
291    }
292
293    #[doc(hidden)]
294    pub fn __unimplemented(resource_type: &'static str, detail: impl Into<String>) -> Self {
295        ResourceErrorBuilder {
296            resource_type: Some(resource_type),
297            detail: detail.into(),
298            variant: ErrorVariant::Unimplemented,
299            resource: ResourceOptional,
300            context: NoContext,
301        }
302    }
303}
304
305impl ResourceErrorBuilder<ResourceAbsent, NeedsReason> {
306    #[doc(hidden)]
307    pub fn __permission_denied(resource_type: &'static str, detail: impl Into<String>) -> Self {
308        ResourceErrorBuilder {
309            resource_type: Some(resource_type),
310            detail: detail.into(),
311            variant: ErrorVariant::PermissionDenied,
312            resource: ResourceAbsent,
313            context: NeedsReason,
314        }
315    }
316}
317
318impl ResourceErrorBuilder<ResourceAbsent, NoContext> {
319    #[doc(hidden)]
320    pub fn __cancelled(resource_type: &'static str, detail: impl Into<String>) -> Self {
321        ResourceErrorBuilder {
322            resource_type: Some(resource_type),
323            detail: detail.into(),
324            variant: ErrorVariant::Cancelled,
325            resource: ResourceAbsent,
326            context: NoContext,
327        }
328    }
329}
330
331impl ResourceErrorBuilder<ResourceOptional, NeedsFieldViolation> {
332    #[doc(hidden)]
333    pub fn __invalid_argument(resource_type: &'static str, detail: impl Into<String>) -> Self {
334        ResourceErrorBuilder {
335            resource_type: Some(resource_type),
336            detail: detail.into(),
337            variant: ErrorVariant::InvalidArgument,
338            resource: ResourceOptional,
339            context: NeedsFieldViolation,
340        }
341    }
342
343    #[doc(hidden)]
344    pub fn __out_of_range(resource_type: &'static str, detail: impl Into<String>) -> Self {
345        ResourceErrorBuilder {
346            resource_type: Some(resource_type),
347            detail: detail.into(),
348            variant: ErrorVariant::OutOfRange,
349            resource: ResourceOptional,
350            context: NeedsFieldViolation,
351        }
352    }
353}
354
355impl ResourceErrorBuilder<ResourceOptional, NeedsQuotaViolation> {
356    #[doc(hidden)]
357    pub fn __resource_exhausted(resource_type: &'static str, detail: impl Into<String>) -> Self {
358        ResourceErrorBuilder {
359            resource_type: Some(resource_type),
360            detail: detail.into(),
361            variant: ErrorVariant::ResourceExhausted,
362            resource: ResourceOptional,
363            context: NeedsQuotaViolation,
364        }
365    }
366}
367
368impl ResourceErrorBuilder<ResourceOptional, NeedsPreconditionViolation> {
369    #[doc(hidden)]
370    pub fn __failed_precondition(resource_type: &'static str, detail: impl Into<String>) -> Self {
371        ResourceErrorBuilder {
372            resource_type: Some(resource_type),
373            detail: detail.into(),
374            variant: ErrorVariant::FailedPrecondition,
375            resource: ResourceOptional,
376            context: NeedsPreconditionViolation,
377        }
378    }
379}
380
381// ---------------------------------------------------------------------------
382// with_resource() — available for ResourceMissing and ResourceOptional
383// ---------------------------------------------------------------------------
384
385impl<Context> ResourceErrorBuilder<ResourceMissing, Context> {
386    #[must_use]
387    pub fn with_resource(
388        self,
389        resource: impl Into<String>,
390    ) -> ResourceErrorBuilder<ResourceSet, Context> {
391        ResourceErrorBuilder {
392            resource_type: self.resource_type,
393            detail: self.detail,
394            variant: self.variant,
395            resource: ResourceSet(resource.into()),
396            context: self.context,
397        }
398    }
399}
400
401impl<Context> ResourceErrorBuilder<ResourceOptional, Context> {
402    #[must_use]
403    pub fn with_resource(
404        self,
405        resource: impl Into<String>,
406    ) -> ResourceErrorBuilder<ResourceSet, Context> {
407        ResourceErrorBuilder {
408            resource_type: self.resource_type,
409            detail: self.detail,
410            variant: self.variant,
411            resource: ResourceSet(resource.into()),
412            context: self.context,
413        }
414    }
415}
416
417// ---------------------------------------------------------------------------
418// with_field_violation() — NeedsFieldViolation → HasFieldViolations, then self
419// ---------------------------------------------------------------------------
420
421impl<Resource> ResourceErrorBuilder<Resource, NeedsFieldViolation> {
422    #[must_use]
423    pub fn with_field_violation(
424        self,
425        field: impl Into<String>,
426        description: impl Into<String>,
427        reason: impl Into<String>,
428    ) -> ResourceErrorBuilder<Resource, HasFieldViolations> {
429        ResourceErrorBuilder {
430            resource_type: self.resource_type,
431            detail: self.detail,
432            variant: self.variant,
433            resource: self.resource,
434            context: HasFieldViolations(vec![FieldViolation::new(field, description, reason)]),
435        }
436    }
437
438    #[must_use]
439    pub fn with_format(
440        self,
441        message: impl Into<String>,
442    ) -> ResourceErrorBuilder<Resource, HasFormatMessage> {
443        let msg = message.into();
444        ResourceErrorBuilder {
445            resource_type: self.resource_type,
446            detail: msg.clone(),
447            variant: self.variant,
448            resource: self.resource,
449            context: HasFormatMessage(msg),
450        }
451    }
452
453    #[must_use]
454    pub fn with_constraint(
455        self,
456        message: impl Into<String>,
457    ) -> ResourceErrorBuilder<Resource, HasConstraintMessage> {
458        let msg = message.into();
459        ResourceErrorBuilder {
460            resource_type: self.resource_type,
461            detail: msg.clone(),
462            variant: self.variant,
463            resource: self.resource,
464            context: HasConstraintMessage(msg),
465        }
466    }
467}
468
469impl<Resource> ResourceErrorBuilder<Resource, HasFieldViolations> {
470    #[must_use]
471    pub fn with_field_violation(
472        mut self,
473        field: impl Into<String>,
474        description: impl Into<String>,
475        reason: impl Into<String>,
476    ) -> Self {
477        self.context
478            .0
479            .push(FieldViolation::new(field, description, reason));
480        self
481    }
482}
483
484// ---------------------------------------------------------------------------
485// with_precondition_violation() — NeedsPreconditionViolation → HasPreconditionViolations
486// ---------------------------------------------------------------------------
487
488impl<Resource> ResourceErrorBuilder<Resource, NeedsPreconditionViolation> {
489    #[must_use]
490    pub fn with_precondition_violation(
491        self,
492        subject: impl Into<String>,
493        description: impl Into<String>,
494        type_: impl Into<String>,
495    ) -> ResourceErrorBuilder<Resource, HasPreconditionViolations> {
496        ResourceErrorBuilder {
497            resource_type: self.resource_type,
498            detail: self.detail,
499            variant: self.variant,
500            resource: self.resource,
501            context: HasPreconditionViolations(vec![PreconditionViolation::new(
502                type_,
503                subject,
504                description,
505            )]),
506        }
507    }
508}
509
510impl<Resource> ResourceErrorBuilder<Resource, HasPreconditionViolations> {
511    #[must_use]
512    pub fn with_precondition_violation(
513        mut self,
514        subject: impl Into<String>,
515        description: impl Into<String>,
516        type_: impl Into<String>,
517    ) -> Self {
518        self.context
519            .0
520            .push(PreconditionViolation::new(type_, subject, description));
521        self
522    }
523}
524
525// ---------------------------------------------------------------------------
526// with_quota_violation() — NeedsQuotaViolation → HasQuotaViolations
527// ---------------------------------------------------------------------------
528
529impl<Resource> ResourceErrorBuilder<Resource, NeedsQuotaViolation> {
530    #[must_use]
531    pub fn with_quota_violation(
532        self,
533        subject: impl Into<String>,
534        description: impl Into<String>,
535    ) -> ResourceErrorBuilder<Resource, HasQuotaViolations> {
536        ResourceErrorBuilder {
537            resource_type: self.resource_type,
538            detail: self.detail,
539            variant: self.variant,
540            resource: self.resource,
541            context: HasQuotaViolations(vec![QuotaViolation::new(subject, description)]),
542        }
543    }
544}
545
546impl<Resource> ResourceErrorBuilder<Resource, HasQuotaViolations> {
547    #[must_use]
548    pub fn with_quota_violation(
549        mut self,
550        subject: impl Into<String>,
551        description: impl Into<String>,
552    ) -> Self {
553        self.context
554            .0
555            .push(QuotaViolation::new(subject, description));
556        self
557    }
558
559    /// Attach a retry hint to the most-recently-pushed quota violation.
560    /// Use for rate-limit-style violations where the upstream knows when
561    /// capacity returns; omit for hard exhaustion (memory, non-replenishing
562    /// quota) where there is no useful retry window.
563    ///
564    /// Silently noop if the violation list is empty — the typestate
565    /// already guarantees at least one violation is present on
566    /// `HasQuotaViolations`, so this branch is unreachable in normal use.
567    #[must_use]
568    pub fn with_quota_violation_retry_after_seconds(mut self, seconds: u64) -> Self {
569        if let Some(last) = self.context.0.last_mut() {
570            last.retry_after_seconds = Some(seconds);
571        }
572        self
573    }
574}
575
576// ---------------------------------------------------------------------------
577// with_reason() — NeedsReason → HasReason
578// ---------------------------------------------------------------------------
579
580impl<Resource> ResourceErrorBuilder<Resource, NeedsReason> {
581    #[must_use]
582    pub fn with_reason(
583        self,
584        reason: impl Into<String>,
585    ) -> ResourceErrorBuilder<Resource, HasReason> {
586        ResourceErrorBuilder {
587            resource_type: self.resource_type,
588            detail: self.detail,
589            variant: self.variant,
590            resource: self.resource,
591            context: HasReason(reason.into()),
592        }
593    }
594}
595
596// ---------------------------------------------------------------------------
597// Public builder-returning constructors on CanonicalError (non-macro categories)
598// ---------------------------------------------------------------------------
599
600impl CanonicalError {
601    #[must_use]
602    pub fn internal(detail: impl Into<String>) -> ResourceErrorBuilder<ResourceAbsent, NoContext> {
603        ResourceErrorBuilder {
604            resource_type: None,
605            detail: detail.into(),
606            variant: ErrorVariant::Internal,
607            resource: ResourceAbsent,
608            context: NoContext,
609        }
610    }
611
612    #[must_use]
613    pub fn service_unavailable() -> ServiceUnavailableBuilder {
614        ServiceUnavailableBuilder {
615            retry_after_seconds: None,
616            detail: None,
617        }
618    }
619
620    #[must_use]
621    pub fn unauthenticated() -> ResourceErrorBuilder<ResourceAbsent, NeedsReason> {
622        ResourceErrorBuilder {
623            resource_type: None,
624            detail: String::from("Authentication required"),
625            variant: ErrorVariant::Unauthenticated,
626            resource: ResourceAbsent,
627            context: NeedsReason,
628        }
629    }
630}
631
632// ---------------------------------------------------------------------------
633// create() — gated by Resource + Context Resolved traits
634// ---------------------------------------------------------------------------
635
636impl<Resource, Context> ResourceErrorBuilder<Resource, Context>
637where
638    Resource: ResourceResolved,
639    Context: ContextResolved,
640{
641    #[must_use]
642    pub fn create(self) -> CanonicalError {
643        let resource_name = self.resource.resolve();
644        let ctx_data = self.context.into_context_data();
645
646        let err = match self.variant {
647            ErrorVariant::NotFound => CanonicalError::__not_found(NotFound::new()),
648            ErrorVariant::AlreadyExists => CanonicalError::__already_exists(AlreadyExists::new()),
649            ErrorVariant::Aborted => CanonicalError::__aborted(Aborted::new(&ctx_data.reason)),
650            ErrorVariant::Unknown => CanonicalError::__unknown(Unknown::new(&self.detail)),
651            ErrorVariant::DeadlineExceeded => {
652                CanonicalError::__deadline_exceeded(DeadlineExceeded::new())
653            }
654            ErrorVariant::PermissionDenied => {
655                CanonicalError::__permission_denied(PermissionDenied::new(&ctx_data.reason))
656            }
657            ErrorVariant::InvalidArgument => {
658                let ctx = if let Some(fmt) = ctx_data.format_message {
659                    InvalidArgument::format(fmt)
660                } else if let Some(cst) = ctx_data.constraint_message {
661                    InvalidArgument::constraint(cst)
662                } else {
663                    InvalidArgument::fields(ctx_data.field_violations)
664                };
665                CanonicalError::__invalid_argument(ctx)
666            }
667            ErrorVariant::OutOfRange => {
668                CanonicalError::__out_of_range(OutOfRange::new(ctx_data.field_violations))
669            }
670            ErrorVariant::ResourceExhausted => CanonicalError::__resource_exhausted(
671                ResourceExhausted::new(ctx_data.quota_violations),
672            ),
673            ErrorVariant::FailedPrecondition => CanonicalError::__failed_precondition(
674                FailedPrecondition::new(ctx_data.precondition_violations),
675            ),
676            ErrorVariant::Cancelled => CanonicalError::__cancelled(Cancelled::new()),
677            ErrorVariant::Unimplemented => CanonicalError::__unimplemented(Unimplemented::new()),
678            ErrorVariant::Internal => CanonicalError::__internal(Internal::new(&self.detail)),
679            ErrorVariant::DataLoss => CanonicalError::__data_loss(DataLoss::new()),
680            ErrorVariant::Unauthenticated => {
681                let mut ctx = Unauthenticated::new();
682                if !ctx_data.reason.is_empty() {
683                    ctx = ctx.with_reason(ctx_data.reason);
684                }
685                CanonicalError::__unauthenticated(ctx)
686            }
687        };
688
689        let mut err = if matches!(
690            err,
691            CanonicalError::Internal { .. } | CanonicalError::Unknown { .. }
692        ) {
693            err
694        } else {
695            err.with_detail(&self.detail)
696        };
697
698        if let Some(rt) = self.resource_type {
699            err = err.with_resource_type(rt);
700        }
701
702        if let Some(rn) = resource_name {
703            err.with_resource(rn)
704        } else {
705            err
706        }
707    }
708}
709
710// ---------------------------------------------------------------------------
711// ServiceUnavailableBuilder — dedicated builder for ServiceUnavailable
712// ---------------------------------------------------------------------------
713
714pub struct ServiceUnavailableBuilder {
715    retry_after_seconds: Option<u64>,
716    detail: Option<String>,
717}
718
719impl ServiceUnavailableBuilder {
720    #[must_use]
721    pub fn with_retry_after_seconds(mut self, seconds: u64) -> Self {
722        self.retry_after_seconds = Some(seconds);
723        self
724    }
725
726    /// Override the default `"Service temporarily unavailable"`
727    /// `Problem.detail` text. Callers that already curated a safe,
728    /// non-secret detail string upstream (e.g. `"authorization
729    /// evaluation failed"`, `"IdP plugin unreachable"`) pass it
730    /// here so the canonical envelope preserves the precise reason
731    /// for the outage rather than collapsing every 503 into the
732    /// same opaque message.
733    ///
734    /// **Caller contract:** the string MUST be safe for the public
735    /// `Problem` body — no DSN fragments, no driver text, no
736    /// hostnames, no operator-supplied config strings. Sources that
737    /// can carry such fragments (raw `DbErr`, vendor SDK error
738    /// `Display`) MUST pass through a redaction step (e.g.
739    /// `redacted_db_diagnostic`) before calling this builder.
740    #[must_use]
741    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
742        self.detail = Some(detail.into());
743        self
744    }
745
746    #[must_use]
747    pub fn create(self) -> CanonicalError {
748        let detail = self
749            .detail
750            .unwrap_or_else(|| "Service temporarily unavailable".to_owned());
751        CanonicalError::__service_unavailable(ServiceUnavailable::new(self.retry_after_seconds))
752            .with_detail(detail)
753    }
754}