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/// What is wrong with a declaration.
122#[derive(Debug, Clone, PartialEq, Eq)]
123#[non_exhaustive]
124pub enum DeclarationErrorKind {
125    /// The router holds no `toasty::Db`.
126    MissingDb,
127    /// Auth is on, but the `Db` does not register the shipped models it needs.
128    MissingAuthModels {
129        /// The unregistered models.
130        models: Vec<&'static str>,
131    },
132    /// `Panel::shell_assets` is set, but the router has no asset bundle.
133    ShellAssetsWithoutBundle,
134    /// A path segment the panel routes cannot be a literal URL segment.
135    InvalidSegment {
136        /// What names the segment: `panel prefix`, `ResourceDef::slug` or `Page::slug`.
137        item: &'static str,
138        /// The refused segment.
139        segment: String,
140        /// Why it is refused.
141        fault: SegmentFault,
142    },
143    /// The prefix overlaps Topcoat's runtime endpoints.
144    PrefixOverlapsRuntime {
145        /// The panel's prefix.
146        prefix: String,
147    },
148    /// The prefix overlaps another panel's.
149    PrefixOverlapsPanel {
150        /// The other panel's prefix.
151        other: String,
152    },
153    /// A `Panel::serve_dir` pattern that does not end in a catch-all.
154    ServeDirWithoutCatchAll {
155        /// The pattern.
156        path: String,
157    },
158    /// The panel serves the same directory pattern twice.
159    ServeDirTwice {
160        /// The pattern.
161        path: String,
162    },
163    /// Another panel already serves the directory pattern.
164    ServeDirTaken {
165        /// The pattern.
166        path: String,
167        /// The serving panel's prefix.
168        panel: String,
169    },
170    /// A second `Panel::home` page.
171    SecondHome,
172    /// The resource is registered on the panel twice.
173    DuplicateResource,
174    /// The resource is not mounted on the request's panel.
175    NotMounted,
176    /// A slug naming a route the panel serves itself.
177    ReservedSlug {
178        /// The slug.
179        slug: String,
180    },
181    /// A slug another resource or page already mounts at.
182    DuplicateSlug {
183        /// The slug.
184        slug: String,
185    },
186    /// Two actions share a `NAME`.
187    DuplicateAction {
188        /// The shared name.
189        name: &'static str,
190    },
191    /// Two relations to the same resource.
192    DuplicateRelation,
193    /// A relation to a resource the panel does not register.
194    UnregisteredRelation,
195    /// A relationship field whose options come from a resource the panel does not register.
196    UnregisteredOptionSource {
197        /// The field.
198        field: String,
199        /// The option source's type.
200        source: &'static str,
201    },
202    /// A lens with more than one step where a single field of the model is needed.
203    TraversalLens {
204        /// The lens's step count.
205        steps: usize,
206    },
207    /// A multi-step lens that resolves to no single column.
208    UnresolvedLens {
209        /// The lens's model.
210        model: &'static str,
211        /// The lens's field indices, step by step.
212        steps: Vec<usize>,
213    },
214    /// A `Tenancy::column` lens that names no field of the model.
215    TenancyColumnNotAField,
216    /// A `Tenancy::via` lens that names one field of the model.
217    TenancyViaOwnColumn,
218    /// A `Tenancy::via` lens whose first step is no `belongs_to` relation.
219    TenancyViaWithoutBelongsTo,
220    /// A `Tenancy::via` resource whose form writes the parent's foreign key other than through a
221    /// relationship field over a tenant-scoped resource of the parent's model.
222    UnguardedForeignKey {
223        /// The `belongs_to` relation the lens steps through.
224        relation: String,
225        /// The foreign-key columns written another way.
226        keys: Vec<String>,
227    },
228    /// A table with no column.
229    NoColumns,
230    /// `Table::paginate(0)`.
231    ZeroPageSize,
232    /// Two table columns share a name.
233    DuplicateColumn {
234        /// The shared name.
235        name: String,
236    },
237    /// Two table filters share a name.
238    DuplicateFilter {
239        /// The shared name.
240        name: String,
241    },
242    /// Two schema fields share a name.
243    DuplicateField {
244        /// The shared name.
245        name: String,
246    },
247    /// A `form()` override that declares no control over a record form with fields.
248    EmptyFormOverride,
249    /// A form schema on a resource whose `Form` serves no form.
250    FormWithoutRecordForm,
251    /// A resource whose policy allows create but that has no form.
252    CreateWithoutForm,
253    /// A form control no record-form field binds, so its input is never written.
254    UnboundControl {
255        /// The control's key.
256        control: String,
257    },
258    /// A form control more than one record-form field binds.
259    ControlBoundTwice {
260        /// The control's key.
261        control: String,
262    },
263    /// A record-form field binding a key the form declares no control for.
264    MissingControl {
265        /// The record-form field.
266        field: String,
267        /// The key it binds.
268        key: String,
269    },
270    /// A control that may be posted empty over a record-form field with no blank answer.
271    NoBlankAnswer {
272        /// The control's key.
273        control: String,
274        /// The record-form field.
275        field: String,
276        /// Whether the control sits inside a `Repeater`; otherwise it is optional.
277        in_repeater: bool,
278    },
279    /// A tenant-scoped resource's record form claims the tenant column.
280    FormClaimsTenantColumn {
281        /// The record-form field.
282        field: String,
283        /// The tenant column.
284        column: String,
285    },
286    /// A relationship field over a model with a composite primary key.
287    CompositeKeyChoice {
288        /// The field.
289        field: String,
290    },
291    /// A field marked unique over a column no unique index covers.
292    UniqueWithoutIndex {
293        /// The field.
294        field: String,
295    },
296    /// `create_columns` names the tenant column.
297    CreateColumnsNameTenant {
298        /// The tenant column.
299        column: String,
300    },
301    /// `create_columns` names a field the model does not have.
302    UnknownCreateColumn {
303        /// The name.
304        column: &'static str,
305    },
306    /// A non-nullable column nothing writes on create.
307    UnwrittenColumn {
308        /// The column.
309        column: String,
310    },
311}
312
313impl fmt::Display for DeclarationErrorKind {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        match self {
316            Self::MissingDb => f.write_str(
317                "the router holds no Db: install it with `.app_context(db)` before mounting the \
318                 panel",
319            ),
320            Self::MissingAuthModels { models } => write!(
321                f,
322                "auth is on, but the Db does not register its shipped models ({}): register them \
323                 with `toasty::models!(…, tablo_core::auth::AdminUser, \
324                 tablo_core::auth::AuthSession)`, or opt out with `.auth(Auth::disabled())`",
325                models.join(", ")
326            ),
327            Self::ShellAssetsWithoutBundle => f.write_str(
328                "shell_assets need the router's asset bundle: install it with `.assets(..)` \
329                 before mounting the panel",
330            ),
331            Self::InvalidSegment {
332                item,
333                segment,
334                fault,
335            } => write!(f, "{item} '{segment}' {fault}"),
336            Self::PrefixOverlapsRuntime { prefix } => write!(
337                f,
338                "prefix '{prefix}' overlaps Topcoat's runtime endpoints at '{}'",
339                crate::topcoat_compat::RUNTIME_PREFIX
340            ),
341            Self::PrefixOverlapsPanel { other } => write!(
342                f,
343                "the prefix overlaps the panel mounted at '{other}': each panel needs a prefix of \
344                 its own"
345            ),
346            Self::ServeDirWithoutCatchAll { path } => write!(
347                f,
348                "serve_dir path '{path}' must end in a catch-all like '/uploads/{{*file}}'"
349            ),
350            Self::ServeDirTwice { path } => write!(f, "serve_dir path '{path}' is declared twice"),
351            Self::ServeDirTaken { path, panel } => write!(
352                f,
353                "serve_dir path '{path}' is already served by the panel mounted at '{panel}'"
354            ),
355            Self::SecondHome => {
356                f.write_str("a home page is already registered: `Panel::home` takes one")
357            }
358            Self::DuplicateResource => f.write_str(
359                "registered twice: a panel mounts each resource type once, so mount a variant \
360                 under its own type",
361            ),
362            Self::NotMounted => f.write_str(
363                "not mounted on the request's panel: register it with `Panel::resource`",
364            ),
365            Self::ReservedSlug { slug } => {
366                write!(f, "slug '{slug}' names a route the panel serves itself")
367            }
368            Self::DuplicateSlug { slug } => write!(
369                f,
370                "slug '{slug}' is taken by another resource or page: each needs a distinct `slug()`"
371            ),
372            Self::DuplicateAction { name } => write!(
373                f,
374                "two actions are named '{name}': each needs a distinct `NAME`"
375            ),
376            Self::DuplicateRelation => {
377                f.write_str("declared twice: each related resource is one relation")
378            }
379            Self::UnregisteredRelation => f.write_str(
380                "the related resource is not registered on this panel: declare it with \
381                 `Panel::resource`",
382            ),
383            Self::UnregisteredOptionSource { field, source } => write!(
384                f,
385                "field '{field}' takes its options from `{source}`, which this panel does not \
386                 register: declare it with `Panel::resource`"
387            ),
388            Self::TraversalLens { steps } => write!(
389                f,
390                "a lens here names one field of its model, not a {steps}-step path"
391            ),
392            Self::UnresolvedLens { model, steps } => write!(
393                f,
394                "lens path {steps:?} resolves to no single column of `{model}`: only embedded \
395                 steps (embedded structs, enum variant fields and `#[document]` fields) bind, not \
396                 relation hops"
397            ),
398            Self::TenancyColumnNotAField => f.write_str(
399                "the `Tenancy::column` lens names no field of the model: name a field typed \
400                 `TenantId`, or use `Tenancy::via` for a tenant reached through a relation",
401            ),
402            Self::TenancyViaOwnColumn => f.write_str(
403                "the `Tenancy::via` lens names one field of the model: use `Tenancy::column` for \
404                 the model's own tenant column",
405            ),
406            Self::TenancyViaWithoutBelongsTo => f.write_str(
407                "the `Tenancy::via` lens does not start at a `belongs_to` relation: the tenant \
408                 must be the parent's its foreign key names",
409            ),
410            Self::UnguardedForeignKey { relation, keys } => write!(
411                f,
412                "foreign key `{}` is not written through a relationship field over a \
413                 tenant-scoped resource of `{relation}`'s model: the write re-checks only such a \
414                 field's key, so any other could attach the row to another tenant's parent",
415                keys.join("`, `")
416            ),
417            Self::NoColumns => f.write_str(
418                "no column: declare columns with `Table::new(columns)` or in `ResourceDef::table`",
419            ),
420            Self::ZeroPageSize => f.write_str("`Table::paginate` needs a page size of at least 1"),
421            Self::DuplicateColumn { name } => write!(
422                f,
423                "two columns are named '{name}': each column needs a distinct name"
424            ),
425            Self::DuplicateFilter { name } => write!(
426                f,
427                "two filters are named '{name}': each filter needs a distinct name"
428            ),
429            Self::DuplicateField { name } => write!(
430                f,
431                "two fields are named '{name}': each input needs a distinct field"
432            ),
433            Self::EmptyFormOverride => f.write_str(
434                "`form()` declares no control over a record form with fields: drop the override \
435                 to render the derived schema",
436            ),
437            Self::FormWithoutRecordForm => f.write_str(
438                "a form schema is declared, but `type Form` serves no form: name the record form \
439                 there",
440            ),
441            Self::CreateWithoutForm => f.write_str(
442                "the policy allows create, but there is no form: name the record form in `type \
443                 Form`",
444            ),
445            Self::UnboundControl { control } => write!(
446                f,
447                "control `{control}` is bound by no record-form field, so what the user types \
448                 there is never written"
449            ),
450            Self::ControlBoundTwice { control } => write!(
451                f,
452                "control `{control}` is bound by more than one record-form field"
453            ),
454            Self::MissingControl { field, key } => write!(
455                f,
456                "record-form field `{field}` binds key `{key}`, but no control declares it"
457            ),
458            Self::NoBlankAnswer {
459                control,
460                field,
461                in_repeater,
462            } => {
463                let place = if *in_repeater {
464                    "sits inside a `Repeater`, so it may be posted empty"
465                } else {
466                    "is optional"
467                };
468                write!(
469                    f,
470                    "control `{control}` {place}, but record-form field `{field}` has no blank \
471                     answer: declare `#[form(blank = ..)]`, make the field an `Option`, or make \
472                     the control required"
473                )
474            }
475            Self::FormClaimsTenantColumn { field, column } => write!(
476                f,
477                "record-form field `{field}` claims the tenant column `{column}`, which the \
478                 framework stamps on create: drop it from the form"
479            ),
480            Self::CompositeKeyChoice { field } => write!(
481                f,
482                "relationship field `{field}` loads a model with a composite primary key, which \
483                 no option value can spell"
484            ),
485            Self::UniqueWithoutIndex { field } => write!(
486                f,
487                "field `{field}` is marked unique, but no unique index covers its column: add \
488                 `#[unique]` (or `#[unique(..)]`) to the model or drop `.unique()`, which would \
489                 otherwise check a rule the database does not enforce"
490            ),
491            Self::CreateColumnsNameTenant { column } => write!(
492                f,
493                "`create_columns` names the tenant column `{column}`, which the framework stamps \
494                 on create: drop it there and delegate to `write_create`"
495            ),
496            Self::UnknownCreateColumn { column } => write!(
497                f,
498                "`create_columns` names `{column}`, which is no field of the model"
499            ),
500            Self::UnwrittenColumn { column } => write!(
501                f,
502                "the policy allows create, but nothing writes the non-nullable column `{column}`: \
503                 the record form has no such field, toasty fills no `#[default(..)]` for it, and \
504                 `create_columns` does not name it, so every create would fail at the driver"
505            ),
506        }
507    }
508}
509
510/// Why a path segment cannot be a literal URL segment.
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512#[non_exhaustive]
513pub enum SegmentFault {
514    /// The segment is empty.
515    Empty,
516    /// The segment is `.` or `..`.
517    Dot,
518    /// The segment holds a character a route segment cannot.
519    Char(char),
520}
521
522impl fmt::Display for SegmentFault {
523    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
524        match self {
525            Self::Empty => f.write_str("is empty"),
526            Self::Dot => f.write_str("is '.' or '..'"),
527            Self::Char(c) => write!(
528                f,
529                "contains {c:?}: quotes, backslashes, control characters, whitespace, URL \
530                 punctuation and the route pattern characters '{{', '}}', '(' and ')' are refused"
531            ),
532        }
533    }
534}
535
536/// Why `segment` cannot be a literal URL segment, if it cannot.
537///
538/// A `const fn`, so an action's `NAME` is checked when the panel's code compiles.
539pub(crate) const fn segment_fault(segment: &str) -> Option<SegmentFault> {
540    let bytes = segment.as_bytes();
541    if bytes.is_empty() {
542        return Some(SegmentFault::Empty);
543    }
544    if matches!(bytes, b"." | b"..") {
545        return Some(SegmentFault::Dot);
546    }
547    let mut at = 0;
548    while at < bytes.len() {
549        let (c, width) = decode_char(bytes, at);
550        if c.is_control()
551            || c.is_whitespace()
552            || matches!(
553                c,
554                '"' | '\\' | '/' | '?' | '#' | '%' | '&' | '=' | '{' | '}' | '(' | ')'
555            )
556        {
557            return Some(SegmentFault::Char(c));
558        }
559        at += width;
560    }
561    None
562}
563
564/// The character starting at byte `at` of valid UTF-8, and its width.
565const fn decode_char(bytes: &[u8], at: usize) -> (char, usize) {
566    let lead = bytes[at] as u32;
567    let (code, width) = if lead < 0x80 {
568        (lead, 1)
569    } else if lead < 0xE0 {
570        ((lead & 0x1F) << 6 | tail(bytes, at + 1), 2)
571    } else if lead < 0xF0 {
572        (
573            (lead & 0x0F) << 12 | tail(bytes, at + 1) << 6 | tail(bytes, at + 2),
574            3,
575        )
576    } else {
577        (
578            (lead & 0x07) << 18
579                | tail(bytes, at + 1) << 12
580                | tail(bytes, at + 2) << 6
581                | tail(bytes, at + 3),
582            4,
583        )
584    };
585    match char::from_u32(code) {
586        Some(c) => (c, width),
587        None => panic!("a str holds valid UTF-8"),
588    }
589}
590
591/// The six payload bits of the continuation byte at `at`.
592const fn tail(bytes: &[u8], at: usize) -> u32 {
593    bytes[at] as u32 & 0x3F
594}
595
596#[cfg(test)]
597mod tests;