Skip to main content

modkit_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
560// ---------------------------------------------------------------------------
561// with_reason() — NeedsReason → HasReason
562// ---------------------------------------------------------------------------
563
564impl<Resource> ResourceErrorBuilder<Resource, NeedsReason> {
565    #[must_use]
566    pub fn with_reason(
567        self,
568        reason: impl Into<String>,
569    ) -> ResourceErrorBuilder<Resource, HasReason> {
570        ResourceErrorBuilder {
571            resource_type: self.resource_type,
572            detail: self.detail,
573            variant: self.variant,
574            resource: self.resource,
575            context: HasReason(reason.into()),
576        }
577    }
578}
579
580// ---------------------------------------------------------------------------
581// Public builder-returning constructors on CanonicalError (non-macro categories)
582// ---------------------------------------------------------------------------
583
584impl CanonicalError {
585    #[must_use]
586    pub fn internal(detail: impl Into<String>) -> ResourceErrorBuilder<ResourceAbsent, NoContext> {
587        ResourceErrorBuilder {
588            resource_type: None,
589            detail: detail.into(),
590            variant: ErrorVariant::Internal,
591            resource: ResourceAbsent,
592            context: NoContext,
593        }
594    }
595
596    #[must_use]
597    pub fn service_unavailable() -> ServiceUnavailableBuilder {
598        ServiceUnavailableBuilder {
599            retry_after_seconds: None,
600            detail: None,
601        }
602    }
603
604    #[must_use]
605    pub fn unauthenticated() -> ResourceErrorBuilder<ResourceAbsent, NeedsReason> {
606        ResourceErrorBuilder {
607            resource_type: None,
608            detail: String::from("Authentication required"),
609            variant: ErrorVariant::Unauthenticated,
610            resource: ResourceAbsent,
611            context: NeedsReason,
612        }
613    }
614}
615
616// ---------------------------------------------------------------------------
617// create() — gated by Resource + Context Resolved traits
618// ---------------------------------------------------------------------------
619
620impl<Resource, Context> ResourceErrorBuilder<Resource, Context>
621where
622    Resource: ResourceResolved,
623    Context: ContextResolved,
624{
625    #[must_use]
626    pub fn create(self) -> CanonicalError {
627        let resource_name = self.resource.resolve();
628        let ctx_data = self.context.into_context_data();
629
630        let err = match self.variant {
631            ErrorVariant::NotFound => CanonicalError::__not_found(NotFound::new()),
632            ErrorVariant::AlreadyExists => CanonicalError::__already_exists(AlreadyExists::new()),
633            ErrorVariant::Aborted => CanonicalError::__aborted(Aborted::new(&ctx_data.reason)),
634            ErrorVariant::Unknown => CanonicalError::__unknown(Unknown::new(&self.detail)),
635            ErrorVariant::DeadlineExceeded => {
636                CanonicalError::__deadline_exceeded(DeadlineExceeded::new())
637            }
638            ErrorVariant::PermissionDenied => {
639                CanonicalError::__permission_denied(PermissionDenied::new(&ctx_data.reason))
640            }
641            ErrorVariant::InvalidArgument => {
642                let ctx = if let Some(fmt) = ctx_data.format_message {
643                    InvalidArgument::format(fmt)
644                } else if let Some(cst) = ctx_data.constraint_message {
645                    InvalidArgument::constraint(cst)
646                } else {
647                    InvalidArgument::fields(ctx_data.field_violations)
648                };
649                CanonicalError::__invalid_argument(ctx)
650            }
651            ErrorVariant::OutOfRange => {
652                CanonicalError::__out_of_range(OutOfRange::new(ctx_data.field_violations))
653            }
654            ErrorVariant::ResourceExhausted => CanonicalError::__resource_exhausted(
655                ResourceExhausted::new(ctx_data.quota_violations),
656            ),
657            ErrorVariant::FailedPrecondition => CanonicalError::__failed_precondition(
658                FailedPrecondition::new(ctx_data.precondition_violations),
659            ),
660            ErrorVariant::Cancelled => CanonicalError::__cancelled(Cancelled::new()),
661            ErrorVariant::Unimplemented => CanonicalError::__unimplemented(Unimplemented::new()),
662            ErrorVariant::Internal => CanonicalError::__internal(Internal::new(&self.detail)),
663            ErrorVariant::DataLoss => CanonicalError::__data_loss(DataLoss::new()),
664            ErrorVariant::Unauthenticated => {
665                let mut ctx = Unauthenticated::new();
666                if !ctx_data.reason.is_empty() {
667                    ctx = ctx.with_reason(ctx_data.reason);
668                }
669                CanonicalError::__unauthenticated(ctx)
670            }
671        };
672
673        let mut err = if matches!(
674            err,
675            CanonicalError::Internal { .. } | CanonicalError::Unknown { .. }
676        ) {
677            err
678        } else {
679            err.with_detail(&self.detail)
680        };
681
682        if let Some(rt) = self.resource_type {
683            err = err.with_resource_type(rt);
684        }
685
686        if let Some(rn) = resource_name {
687            err.with_resource(rn)
688        } else {
689            err
690        }
691    }
692}
693
694// ---------------------------------------------------------------------------
695// ServiceUnavailableBuilder — dedicated builder for ServiceUnavailable
696// ---------------------------------------------------------------------------
697
698pub struct ServiceUnavailableBuilder {
699    retry_after_seconds: Option<u64>,
700    detail: Option<String>,
701}
702
703impl ServiceUnavailableBuilder {
704    #[must_use]
705    pub fn with_retry_after_seconds(mut self, seconds: u64) -> Self {
706        self.retry_after_seconds = Some(seconds);
707        self
708    }
709
710    /// Override the default `"Service temporarily unavailable"`
711    /// `Problem.detail` text. Callers that already curated a safe,
712    /// non-secret detail string upstream (e.g. `"authorization
713    /// evaluation failed"`, `"IdP plugin unreachable"`) pass it
714    /// here so the canonical envelope preserves the precise reason
715    /// for the outage rather than collapsing every 503 into the
716    /// same opaque message.
717    ///
718    /// **Caller contract:** the string MUST be safe for the public
719    /// `Problem` body — no DSN fragments, no driver text, no
720    /// hostnames, no operator-supplied config strings. Sources that
721    /// can carry such fragments (raw `DbErr`, vendor SDK error
722    /// `Display`) MUST pass through a redaction step (e.g.
723    /// `redacted_db_diagnostic`) before calling this builder.
724    #[must_use]
725    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
726        self.detail = Some(detail.into());
727        self
728    }
729
730    #[must_use]
731    pub fn create(self) -> CanonicalError {
732        let detail = self
733            .detail
734            .unwrap_or_else(|| "Service temporarily unavailable".to_owned());
735        CanonicalError::__service_unavailable(ServiceUnavailable::new(self.retry_after_seconds))
736            .with_detail(detail)
737    }
738}