Skip to main content

tablo_core/
declaration.rs

1//! The declaration mistakes a panel refuses to mount with, and the one place their wording lives.
2
3use std::fmt;
4
5/// Why [`RouterBuilderPanelExt::panel`](crate::RouterBuilderPanelExt::panel) refused a panel:
6/// every declaration mistake it found.
7///
8/// The router's builder returns it as a [`topcoat::Error`]; recover it with
9/// `error.downcast_ref::<MountError>()`.
10#[derive(Debug)]
11pub struct MountError {
12    panel: String,
13    errors: Vec<DeclarationError>,
14}
15
16impl MountError {
17    pub(crate) fn new(panel: &str, errors: Vec<DeclarationError>) -> Self {
18        Self {
19            panel: panel.to_string(),
20            errors,
21        }
22    }
23
24    /// The refused panel's prefix.
25    pub fn panel(&self) -> &str {
26        &self.panel
27    }
28
29    /// Every mistake found, in declaration order.
30    pub fn errors(&self) -> &[DeclarationError] {
31        &self.errors
32    }
33}
34
35impl fmt::Display for MountError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        write!(f, "panel '{}' cannot mount:", self.panel)?;
38        for error in &self.errors {
39            write!(f, "\n  - {error}")?;
40        }
41        Ok(())
42    }
43}
44
45impl std::error::Error for MountError {}
46
47/// One declaration mistake: who declares it, where, and what is wrong.
48#[derive(Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub struct DeclarationError {
51    /// The resource or page type that declares it; `None` for the panel's own configuration.
52    pub resource: Option<&'static str>,
53    /// The part of the declaration it is in.
54    pub site: Site,
55    /// What is wrong.
56    pub kind: DeclarationErrorKind,
57}
58
59impl DeclarationError {
60    /// A mistake in the panel's own configuration.
61    pub(crate) fn panel(kind: DeclarationErrorKind) -> Self {
62        Self {
63            resource: None,
64            site: Site::Panel,
65            kind,
66        }
67    }
68
69    /// A mistake in `T`'s declaration.
70    pub(crate) fn of<T>(site: Site, kind: DeclarationErrorKind) -> Self {
71        Self {
72            resource: Some(std::any::type_name::<T>()),
73            site,
74            kind,
75        }
76    }
77}
78
79impl fmt::Display for DeclarationError {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        if let Some(resource) = self.resource {
82            write!(f, "`{resource}`")?;
83            match &self.site {
84                Site::Panel | Site::Registration => {}
85                Site::Tenancy => f.write_str(" tenancy")?,
86                Site::Table => f.write_str(" table")?,
87                Site::Form => f.write_str(" form")?,
88                Site::View => f.write_str(" view")?,
89                Site::Relation(key) => write!(f, " relation `{key}`")?,
90            }
91            f.write_str(": ")?;
92        }
93        write!(f, "{}", self.kind)
94    }
95}
96
97impl std::error::Error for DeclarationError {}
98
99/// The part of a declaration a [`DeclarationError`] is in.
100#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum Site {
103    /// The panel's own configuration: its prefix, served directories, assets and auth.
104    Panel,
105    /// The resource or page itself: its registration, slug, actions or create columns, or a
106    /// second home page.
107    Registration,
108    /// `ResourceDef::tenancy`.
109    Tenancy,
110    /// `ResourceDef::table`.
111    Table,
112    /// `ResourceDef::form`, checked against the record form.
113    Form,
114    /// `ResourceDef::view`.
115    View,
116    /// The `ResourceDef::relation` to the resource with this slug, or this type when the panel
117    /// does not register it.
118    Relation(String),
119}
120
121/// Why an action's input cannot be served:
122/// [`DeclarationErrorKind::ActionInput`].
123#[derive(Debug, Clone, PartialEq, Eq)]
124#[non_exhaustive]
125pub enum ActionInputFault {
126    /// A field named like a key the action's POST carries itself: `csrf_token`, `confirm` or
127    /// `ids`.
128    ReservedField(String),
129    /// A file field, whose upload an action's POST does not read.
130    FileField(String),
131    /// No field, but a parse that refuses an empty submission, so the action could never run.
132    RefusesEmpty,
133    /// A dependent choice, whose options no route serves an action's input.
134    DependentChoice(String),
135}
136
137/// Why a panel's sign-up page cannot be served: [`DeclarationErrorKind::SignUp`].
138#[derive(Debug, Clone, PartialEq, Eq)]
139#[non_exhaustive]
140pub enum SignUpFault {
141    /// `Auth::registration` on `Auth::disabled`, which serves no sign-in to land on.
142    AuthDisabled,
143    /// The registrar creates another user type than the authenticator loads.
144    UserType {
145        /// The registrar's `User` type.
146        registrar: &'static str,
147    },
148    /// A field named like a key the sign-up POST carries itself: `csrf_token` or `next`.
149    ReservedField(String),
150    /// A file field, whose upload the sign-up POST does not read.
151    FileField(String),
152    /// A relationship choice, whose options would list records to a visitor nobody signed in.
153    RelationshipField(String),
154}
155
156impl fmt::Display for SignUpFault {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            Self::AuthDisabled => f.write_str(
160                "registration needs auth: `Auth::disabled()` serves no sign-in for a new \
161             account to land on",
162            ),
163            Self::UserType { registrar } => write!(
164                f,
165                "the registrar creates `{registrar}`, but the authenticator loads another \
166             type: register the type the panel signs in"
167            ),
168            Self::ReservedField(field) => write!(
169                f,
170                "the sign-up form declares a field named '{field}', which its POST carries \
171             itself: rename the field"
172            ),
173            Self::FileField(field) => write!(
174                f,
175                "the sign-up form declares a file field '{field}', which its POST does not \
176             upload: ask for the file once the account signs in"
177            ),
178            Self::RelationshipField(field) => write!(
179                f,
180                "the sign-up form declares a relationship choice '{field}', which would list \
181             records to a visitor nobody signed in: offer static options"
182            ),
183        }
184    }
185}
186
187/// Why a many-to-many field or relation cannot link records:
188/// [`DeclarationErrorKind::ManyToMany`].
189#[derive(Debug, Clone, PartialEq, Eq)]
190#[non_exhaustive]
191pub enum ManyToManyFault {
192    /// The field is no `#[has_many(via = joins.target)]` stepping through a `has_many` to a join
193    /// model, then the join model's `belongs_to` to the target.
194    NotJoinModel,
195    /// A foreign key of the join model spans several columns, or references no single-column
196    /// primary key.
197    CompositeKey,
198    /// The join model has a column a new link cannot fill: not nullable, not `#[auto]`, and not
199    /// one of its two foreign keys.
200    UnwritableColumn {
201        /// The join model.
202        model: String,
203        /// The column.
204        column: String,
205    },
206    /// The form's control for the field is not a multiple choice over a relationship.
207    NotMultipleChoice,
208    /// The relationship's records are not the target's.
209    OtherSource,
210    /// A multiple choice in a resource's form over no many-to-many field, which no column stores.
211    NoJoin,
212}
213
214/// What is wrong with a declaration.
215#[derive(Debug, Clone, PartialEq, Eq)]
216#[non_exhaustive]
217pub enum DeclarationErrorKind {
218    /// The router holds no `toasty::Db`.
219    MissingDb,
220    /// Auth is on, but the `Db` does not register the shipped models it needs.
221    MissingAuthModels {
222        /// The unregistered models.
223        models: Vec<&'static str>,
224    },
225    /// `Panel::shell_assets` is set, but the router has no asset bundle.
226    ShellAssetsWithoutBundle,
227    /// A form declares a file field, but the panel installs no `Uploader` to store its bytes.
228    FileFieldWithoutUploader {
229        /// The file field's name.
230        field: String,
231    },
232    /// A path segment the panel routes cannot be a literal URL segment.
233    InvalidSegment {
234        /// What names the segment: `panel prefix`, `ResourceDef::slug` or `Page::slug`.
235        item: &'static str,
236        /// The refused segment.
237        segment: String,
238        /// Why it is refused.
239        fault: SegmentFault,
240    },
241    /// The prefix overlaps Topcoat's runtime endpoints.
242    PrefixOverlapsRuntime {
243        /// The panel's prefix.
244        prefix: String,
245    },
246    /// The prefix overlaps another panel's.
247    PrefixOverlapsPanel {
248        /// The other panel's prefix.
249        other: String,
250    },
251    /// A `Panel::serve_dir` pattern that does not end in a catch-all.
252    ServeDirWithoutCatchAll {
253        /// The pattern.
254        path: String,
255    },
256    /// The panel serves the same directory pattern twice.
257    ServeDirTwice {
258        /// The pattern.
259        path: String,
260    },
261    /// Another panel already serves the directory pattern.
262    ServeDirTaken {
263        /// The pattern.
264        path: String,
265        /// The serving panel's prefix.
266        panel: String,
267    },
268    /// A second `Panel::home` page.
269    SecondHome,
270    /// The resource is registered on the panel twice.
271    DuplicateResource,
272    /// The resource is not mounted on the context's panel.
273    NotMounted,
274    /// A slug naming a route the panel serves itself.
275    ReservedSlug {
276        /// The slug.
277        slug: String,
278    },
279    /// A slug another resource or page already mounts at.
280    DuplicateSlug {
281        /// The slug.
282        slug: String,
283    },
284    /// Two actions share a `NAME`.
285    DuplicateAction {
286        /// The shared name.
287        name: &'static str,
288    },
289    /// An action's input cannot be served.
290    ActionInput {
291        /// The action's `NAME`.
292        action: &'static str,
293        /// What is wrong with it.
294        fault: ActionInputFault,
295    },
296    /// The panel's sign-up page cannot be served.
297    SignUp(SignUpFault),
298    /// Two relations to the same resource.
299    DuplicateRelation,
300    /// A relation to a resource the panel does not register.
301    UnregisteredRelation,
302    /// A relationship field whose options come from a resource the panel does not register.
303    UnregisteredOptionSource {
304        /// The field.
305        field: String,
306        /// The option source's type.
307        source: &'static str,
308    },
309    /// A relation column labelling its records by a resource the panel does not register.
310    UnregisteredLabelSource {
311        /// The column.
312        column: String,
313        /// The label source's type.
314        source: &'static str,
315    },
316    /// A lens with more than one step where a single field of the model is needed.
317    TraversalLens {
318        /// The lens's step count.
319        steps: usize,
320    },
321    /// A multi-step lens that resolves to no single column.
322    UnresolvedLens {
323        /// The lens's model.
324        model: &'static str,
325        /// The lens's field indices, step by step.
326        steps: Vec<usize>,
327    },
328    /// An embedded path or value its declaration never bound to the app schema.
329    Unbound {
330        /// The path's model, or the embedded value's type.
331        item: &'static str,
332    },
333    /// A `Tenancy::column` lens that names no field of the model.
334    TenancyColumnNotAField,
335    /// A `Tenancy::via` lens that names one field of the model.
336    TenancyViaOwnColumn,
337    /// A `Tenancy::via` lens whose first step is no `belongs_to` relation.
338    TenancyViaWithoutBelongsTo,
339    /// A `Tenancy::via` resource whose form writes the parent's foreign key other than through a
340    /// relationship field over a tenant-scoped resource of the parent's model.
341    UnguardedForeignKey {
342        /// The `belongs_to` relation the lens steps through.
343        relation: String,
344        /// The foreign-key columns written another way.
345        keys: Vec<String>,
346    },
347    /// A table with no column.
348    NoColumns,
349    /// `Table::paginate(0)`.
350    ZeroPageSize,
351    /// Two table columns share a name.
352    DuplicateColumn {
353        /// The shared name.
354        name: String,
355    },
356    /// Two table filters share a name.
357    DuplicateFilter {
358        /// The shared name.
359        name: String,
360    },
361    /// A select filter option whose value the field's type does not parse, so choosing it
362    /// filters nothing.
363    UnparsedFilterOption {
364        /// The filter's name.
365        filter: String,
366        /// The option's value.
367        value: String,
368    },
369    /// Two schema fields share a name.
370    DuplicateField {
371        /// The shared name.
372        name: String,
373    },
374    /// A condition watches a field the schema does not place.
375    UnplacedWatchedField {
376        /// The watched field.
377        field: String,
378    },
379    /// A condition watches a field that another condition hides while what it guards still
380    /// shows.
381    HiddenWatchedField {
382        /// The watched field.
383        field: String,
384    },
385    /// A condition names a value its watched field never posts: not one of a choice's options, or
386    /// neither `true` nor `false` for a toggle.
387    UnpostedConditionValue {
388        /// The watched field.
389        field: String,
390        /// The value.
391        value: String,
392    },
393    /// A dependent choice's column belongs to another model than its relationship's source, or
394    /// the choice has no relationship.
395    MisdeclaredDependentChoice {
396        /// The dependent choice.
397        field: String,
398    },
399    /// A dependent choice that takes several options, or that depends on one that does: a
400    /// multiple choice's key holds a list, which narrows nothing.
401    MultipleDependentChoice {
402        /// The dependent choice.
403        field: String,
404    },
405    /// A dependent choice depends on a field the schema does not place.
406    UnplacedParentField {
407        /// The dependent choice.
408        field: String,
409        /// The field it depends on.
410        parent: String,
411    },
412    /// A field a condition hides has no blank answer, so a submission hiding it cannot parse.
413    RequiredConditionalField {
414        /// The field.
415        field: String,
416    },
417    /// A resource whose policy allows create but that has no form.
418    CreateWithoutForm,
419    /// A record-form field binding a key no control posts, after the form renders every field
420    /// it does not place: a hand-written [`RecordForm::control`](crate::RecordForm::control)
421    /// that renders no control for it.
422    MissingControl {
423        /// The record-form field.
424        field: String,
425        /// The key it binds.
426        key: String,
427    },
428    /// A tenant-scoped resource's record form claims the tenant column.
429    FormClaimsTenantColumn {
430        /// The record-form field.
431        field: String,
432        /// The tenant column.
433        column: String,
434    },
435    /// A choice field with neither options nor a relationship.
436    EmptyChoice {
437        /// The field.
438        field: String,
439    },
440    /// A relationship field over a model with a composite primary key.
441    CompositeKeyChoice {
442        /// The field.
443        field: String,
444    },
445    /// A field marked unique over a column no unique index covers.
446    UniqueWithoutIndex {
447        /// The field.
448        field: String,
449    },
450    /// A unique field whose non-nullable column stores one value for every empty submission.
451    OptionalUnique {
452        /// The field.
453        field: String,
454    },
455    /// [`create_column`](crate::ResourceDef::create_column) names the tenant column.
456    CreateColumnsNameTenant {
457        /// The tenant column.
458        column: String,
459    },
460    /// A non-nullable column nothing writes on create.
461    UnwrittenColumn {
462        /// The column.
463        column: String,
464    },
465    /// A many-to-many field or relation whose join model cannot link records.
466    ManyToMany {
467        /// The field, or the relation's `via` field.
468        field: String,
469        /// What is wrong.
470        fault: ManyToManyFault,
471    },
472}
473
474impl fmt::Display for DeclarationErrorKind {
475    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
476        match self {
477            Self::MissingDb => f.write_str(
478                "the router holds no Db: install it with `.app_context(db)` before mounting the \
479                 panel",
480            ),
481            Self::MissingAuthModels { models } => write!(
482                f,
483                "auth is on, but the Db does not register its shipped models ({}): register them \
484                 with `toasty::models!(…, tablo_core::auth::AdminUser, \
485                 tablo_core::auth::AuthSession)`, or opt out with `.auth(Auth::disabled())`",
486                models.join(", ")
487            ),
488            Self::ShellAssetsWithoutBundle => f.write_str(
489                "shell_assets need the router's asset bundle: install it with `.assets(..)` \
490                 before mounting the panel",
491            ),
492            Self::FileFieldWithoutUploader { field } => write!(
493                f,
494                "file field `{field}` needs an uploader to store its bytes: install one with \
495                 `Panel::uploads`"
496            ),
497            Self::InvalidSegment {
498                item,
499                segment,
500                fault,
501            } => write!(f, "{item} '{segment}' {fault}"),
502            Self::PrefixOverlapsRuntime { prefix } => write!(
503                f,
504                "prefix '{prefix}' overlaps Topcoat's runtime endpoints at '{}'",
505                crate::topcoat_compat::RUNTIME_PREFIX
506            ),
507            Self::PrefixOverlapsPanel { other } => write!(
508                f,
509                "the prefix overlaps the panel mounted at '{other}': each panel needs a prefix of \
510                 its own"
511            ),
512            Self::ServeDirWithoutCatchAll { path } => write!(
513                f,
514                "serve_dir path '{path}' must end in a catch-all like '/uploads/{{*file}}'"
515            ),
516            Self::ServeDirTwice { path } => write!(f, "serve_dir path '{path}' is declared twice"),
517            Self::ServeDirTaken { path, panel } => write!(
518                f,
519                "serve_dir path '{path}' is already served by the panel mounted at '{panel}'"
520            ),
521            Self::SecondHome => {
522                f.write_str("a home page is already registered: `Panel::home` takes one")
523            }
524            Self::DuplicateResource => f.write_str(
525                "registered twice: a panel mounts each resource type once, so mount a variant \
526                 under its own type",
527            ),
528            Self::NotMounted => f.write_str(
529                "not mounted on the context's panel: register it with `Panel::resource`",
530            ),
531            Self::ReservedSlug { slug } => {
532                write!(f, "slug '{slug}' names a route the panel serves itself")
533            }
534            Self::DuplicateSlug { slug } => write!(
535                f,
536                "slug '{slug}' is taken by another resource or page: each needs a distinct `slug()`"
537            ),
538            Self::DuplicateAction { name } => write!(
539                f,
540                "two actions are named '{name}': each needs a distinct `NAME`"
541            ),
542            Self::ActionInput { action, fault } => match fault {
543                ActionInputFault::ReservedField(field) => write!(
544                    f,
545                    "action '{action}' declares an input field named '{field}', which its POST \
546                     carries itself: rename the field"
547                ),
548                ActionInputFault::FileField(field) => write!(
549                    f,
550                    "action '{action}' declares a file field '{field}' in its input, which an \
551                     action's POST does not upload: take the file in a record form"
552                ),
553                ActionInputFault::RefusesEmpty => write!(
554                    f,
555                    "action '{action}' declares no input field, but its input refuses an empty \
556                     submission: declare the fields it parses, or name `()`"
557                ),
558                ActionInputFault::DependentChoice(field) => write!(
559                    f,
560                    "action '{action}' declares a dependent choice '{field}' in its input, whose \
561                     options only a resource's form refreshes: drop `depends_on`"
562                ),
563            },
564            Self::SignUp(fault) => fault.fmt(f),
565            Self::DuplicateRelation => {
566                f.write_str("declared twice: each related resource is one relation")
567            }
568            Self::UnregisteredRelation => f.write_str(
569                "the related resource is not registered on this panel: declare it with \
570                 `Panel::resource`",
571            ),
572            Self::UnregisteredOptionSource { field, source } => write!(
573                f,
574                "field '{field}' takes its options from `{source}`, which this panel does not \
575                 register: declare it with `Panel::resource`"
576            ),
577            Self::UnregisteredLabelSource { column, source } => write!(
578                f,
579                "column '{column}' labels its records by `{source}`, which this panel does not \
580                 register: declare it with `Panel::resource`"
581            ),
582            Self::TraversalLens { steps } => write!(
583                f,
584                "a lens here names one field of its model, not a {steps}-step path"
585            ),
586            Self::UnresolvedLens { model, steps } => write!(
587                f,
588                "lens path {steps:?} resolves to no single column of `{model}`: only embedded \
589                 steps (embedded structs, enum variant fields and `#[document]` fields) bind, not \
590                 relation hops"
591            ),
592            Self::Unbound { item } => write!(
593                f,
594                "an embedded path into `{item}` is not bound to the app schema: a panel binds the \
595                 declarations it mounts; bind one built outside a panel with `.bind(&db)`"
596            ),
597            Self::TenancyColumnNotAField => f.write_str(
598                "the `Tenancy::column` lens names no field of the model: name a field typed \
599                 `TenantId`, or use `Tenancy::via` for a tenant reached through a relation",
600            ),
601            Self::TenancyViaOwnColumn => f.write_str(
602                "the `Tenancy::via` lens names one field of the model: use `Tenancy::column` for \
603                 the model's own tenant column",
604            ),
605            Self::TenancyViaWithoutBelongsTo => f.write_str(
606                "the `Tenancy::via` lens does not start at a `belongs_to` relation: the tenant \
607                 must be the parent's its foreign key names",
608            ),
609            Self::UnguardedForeignKey { relation, keys } => write!(
610                f,
611                "foreign key `{}` is not written through a relationship field over a \
612                 tenant-scoped resource of `{relation}`'s model: the write re-checks only such a \
613                 field's key, so any other could attach the row to another tenant's parent",
614                keys.join("`, `")
615            ),
616            Self::NoColumns => f.write_str(
617                "no column: declare columns with `Table::new(columns)` or in `ResourceDef::table`",
618            ),
619            Self::ZeroPageSize => f.write_str("`Table::paginate` needs a page size of at least 1"),
620            Self::DuplicateColumn { name } => write!(
621                f,
622                "two columns are named '{name}': each column needs a distinct name"
623            ),
624            Self::DuplicateFilter { name } => write!(
625                f,
626                "two filters are named '{name}': each filter needs a distinct name"
627            ),
628            Self::UnparsedFilterOption { filter, value } => write!(
629                f,
630                "filter '{filter}' offers '{value}', which its field's type does not parse: spell \
631                 each option as the field's form value, or take the type's own with \
632                 `SelectFilter::of`"
633            ),
634            Self::DuplicateField { name } => write!(
635                f,
636                "two fields are named '{name}': each input needs a distinct field"
637            ),
638            Self::UnplacedWatchedField { field } => write!(
639                f,
640                "a condition watches field '{field}', which the schema does not place: place it \
641                 in the same schema"
642            ),
643            Self::HiddenWatchedField { field } => write!(
644                f,
645                "a condition watches field '{field}', which a condition hides while the guarded \
646                 field or block still shows: place the guarded one inside the block that hides \
647                 '{field}'"
648            ),
649            Self::UnpostedConditionValue { field, value } => write!(
650                f,
651                "a condition shows a field while '{field}' posts '{value}', which it never posts: \
652                 name one of its option values, or `true` or `false` for a toggle"
653            ),
654            Self::MisdeclaredDependentChoice { field } => write!(
655                f,
656                "choice '{field}' depends on a column its options do not have: call \
657                 `relationship::<R>()` and name a column of `R`'s model in `depends_on`"
658            ),
659            Self::MultipleDependentChoice { field } => write!(
660                f,
661                "choice '{field}' depends on another field through a multiple choice, which \
662                 neither narrows another choice nor is narrowed: drop `depends_on` or `multiple`"
663            ),
664            Self::UnplacedParentField { field, parent } => write!(
665                f,
666                "choice '{field}' depends on field '{parent}', which the schema does not place: \
667                 place it in the same schema"
668            ),
669            Self::RequiredConditionalField { field } => write!(
670                f,
671                "field '{field}' is conditional but has no blank answer: a submission that hides \
672                 it posts nothing, so make it an `Option` or give it `#[form(blank = ..)]`"
673            ),
674            Self::CreateWithoutForm => f.write_str(
675                "the policy allows create, but there is no form: name the record form in `type \
676                 Form`",
677            ),
678            Self::MissingControl { field, key } => write!(
679                f,
680                "record-form field `{field}` binds key `{key}`, but no control posts it: \
681                 `RecordForm::control` must render the field's control"
682            ),
683            Self::FormClaimsTenantColumn { field, column } => write!(
684                f,
685                "record-form field `{field}` claims the tenant column `{column}`, which the \
686                 framework stamps on create: drop it from the form"
687            ),
688            Self::EmptyChoice { field } => write!(
689                f,
690                "choice field `{field}` declares no options and no relationship, so it offers \
691                 nothing to choose: add `.options(..)` or `.relationship::<Source>()`, or \
692                 `#[form(relationship = Source)]` on a record form's field"
693            ),
694            Self::CompositeKeyChoice { field } => write!(
695                f,
696                "relationship field `{field}` loads a model with a composite primary key, which \
697                 no option value can spell"
698            ),
699            Self::UniqueWithoutIndex { field } => write!(
700                f,
701                "field `{field}` is marked unique, but no unique index covers its column: add \
702                 `#[unique]` (or `#[unique(..)]`) to the model or drop `.unique()`, which would \
703                 otherwise check a rule the database does not enforce"
704            ),
705            Self::OptionalUnique { field } => write!(
706                f,
707                "field `{field}` is marked unique, but its record-form field answers an empty \
708                 submission and its column is not nullable, so every empty submission stores \
709                 the same value: make the field required, or an `Option`"
710            ),
711            Self::CreateColumnsNameTenant { column } => write!(
712                f,
713                "`create_column` names the tenant column `{column}`, which the framework stamps \
714                 on create: drop it there and delegate to `write_create`"
715            ),
716            Self::UnwrittenColumn { column } => write!(
717                f,
718                "the policy allows create, but nothing writes the non-nullable column `{column}`: \
719                 the record form has no such field, toasty fills no `#[default(..)]` for it, and \
720                 no `create_column` names it, so every create would fail at the driver"
721            ),
722            Self::ManyToMany { field, fault } => match fault {
723                ManyToManyFault::NotJoinModel => write!(
724                    f,
725                    "`{field}` is no many-to-many field: declare it \
726                     `#[has_many(via = joins.target)]`, through a `has_many` to a join model and \
727                     the join model's `belongs_to` to the target"
728                ),
729                ManyToManyFault::CompositeKey => write!(
730                    f,
731                    "`{field}`'s join model holds a key of several columns, or one referencing no \
732                     single-column primary key: a link writes one column per side"
733                ),
734                ManyToManyFault::UnwritableColumn { model, column } => write!(
735                    f,
736                    "`{field}`'s join model `{model}` has a column `{column}` a new link cannot \
737                     fill: make it nullable or `#[auto]`, or link the records in your own record fn"
738                ),
739                ManyToManyFault::NotMultipleChoice => write!(
740                    f,
741                    "`{field}` is a many-to-many field, so its control is a multiple choice over \
742                     a relationship: `.choice().relationship::<R>().multiple()`"
743                ),
744                ManyToManyFault::OtherSource => write!(
745                    f,
746                    "`{field}` offers another model's records than the ones its join model links"
747                ),
748                ManyToManyFault::NoJoin => write!(
749                    f,
750                    "multiple choice `{field}` names no many-to-many field of the model, so \
751                     nothing would store its records: bind it to a `#[has_many(via = ..)]` field"
752                ),
753            },
754        }
755    }
756}
757
758/// Why a path segment cannot be a literal URL segment.
759#[derive(Debug, Clone, Copy, PartialEq, Eq)]
760#[non_exhaustive]
761pub enum SegmentFault {
762    /// The segment is empty.
763    Empty,
764    /// The segment is `.` or `..`.
765    Dot,
766    /// The segment holds a character a route segment cannot.
767    Char(char),
768}
769
770impl fmt::Display for SegmentFault {
771    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
772        match self {
773            Self::Empty => f.write_str("is empty"),
774            Self::Dot => f.write_str("is '.' or '..'"),
775            Self::Char(c) => write!(
776                f,
777                "contains {c:?}: quotes, backslashes, control characters, whitespace, URL \
778                 punctuation and the route pattern characters '{{', '}}', '(' and ')' are refused"
779            ),
780        }
781    }
782}
783
784/// Why `segment` cannot be a literal URL segment, if it cannot.
785///
786/// A `const fn`, so an action's `NAME` is checked when the panel's code compiles.
787pub(crate) const fn segment_fault(segment: &str) -> Option<SegmentFault> {
788    let bytes = segment.as_bytes();
789    if bytes.is_empty() {
790        return Some(SegmentFault::Empty);
791    }
792    if matches!(bytes, b"." | b"..") {
793        return Some(SegmentFault::Dot);
794    }
795    let mut at = 0;
796    while at < bytes.len() {
797        let (c, width) = decode_char(bytes, at);
798        if c.is_control()
799            || c.is_whitespace()
800            || matches!(
801                c,
802                '"' | '\\' | '/' | '?' | '#' | '%' | '&' | '=' | '{' | '}' | '(' | ')'
803            )
804        {
805            return Some(SegmentFault::Char(c));
806        }
807        at += width;
808    }
809    None
810}
811
812/// The character starting at byte `at` of valid UTF-8, and its width.
813const fn decode_char(bytes: &[u8], at: usize) -> (char, usize) {
814    let lead = bytes[at] as u32;
815    let (code, width) = if lead < 0x80 {
816        (lead, 1)
817    } else if lead < 0xE0 {
818        ((lead & 0x1F) << 6 | tail(bytes, at + 1), 2)
819    } else if lead < 0xF0 {
820        (
821            (lead & 0x0F) << 12 | tail(bytes, at + 1) << 6 | tail(bytes, at + 2),
822            3,
823        )
824    } else {
825        (
826            (lead & 0x07) << 18
827                | tail(bytes, at + 1) << 12
828                | tail(bytes, at + 2) << 6
829                | tail(bytes, at + 3),
830            4,
831        )
832    };
833    match char::from_u32(code) {
834        Some(c) => (c, width),
835        None => panic!("a str holds valid UTF-8"),
836    }
837}
838
839/// The six payload bits of the continuation byte at `at`.
840const fn tail(bytes: &[u8], at: usize) -> u32 {
841    bytes[at] as u32 & 0x3F
842}
843
844#[cfg(test)]
845mod tests;