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