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