1use std::fmt;
4
5#[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 pub fn panel(&self) -> &str {
26 &self.panel
27 }
28
29 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#[derive(Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub struct DeclarationError {
51 pub resource: Option<&'static str>,
53 pub site: Site,
55 pub kind: DeclarationErrorKind,
57}
58
59impl DeclarationError {
60 pub(crate) fn panel(kind: DeclarationErrorKind) -> Self {
62 Self {
63 resource: None,
64 site: Site::Panel,
65 kind,
66 }
67 }
68
69 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::Detail => f.write_str(" detail")?,
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#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum Site {
103 Panel,
105 Registration,
108 Tenancy,
110 Table,
112 Form,
114 Detail,
116 Relation(String),
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
124#[non_exhaustive]
125pub enum ActionInputFault {
126 ReservedField(String),
129 FileField(String),
131 RefusesEmpty,
133 DependentChoice(String),
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139#[non_exhaustive]
140pub enum SignUpFault {
141 AuthDisabled,
143 UserType {
145 registrar: &'static str,
147 },
148 ReservedField(String),
150 FileField(String),
152 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#[derive(Debug, Clone, PartialEq, Eq)]
190#[non_exhaustive]
191pub enum ManyToManyFault {
192 NotJoinModel,
195 CompositeKey,
198 UnwritableColumn {
201 model: String,
203 column: String,
205 },
206 NotMultipleChoice,
208 OtherSource,
210 NoJoin,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
216#[non_exhaustive]
217pub enum DeclarationErrorKind {
218 MissingDb,
220 MissingAuthModels {
222 models: Vec<&'static str>,
224 },
225 ShellAssetsWithoutBundle,
227 FileFieldWithoutUploader {
229 field: String,
231 },
232 InvalidSegment {
234 item: &'static str,
236 segment: String,
238 fault: SegmentFault,
240 },
241 PrefixOverlapsRuntime {
243 prefix: String,
245 },
246 PrefixOverlapsPanel {
248 other: String,
250 },
251 ServeDirWithoutCatchAll {
253 path: String,
255 },
256 ServeDirTwice {
258 path: String,
260 },
261 ServeDirTaken {
263 path: String,
265 panel: String,
267 },
268 SecondHome,
270 DuplicateResource,
272 DuplicatePage,
274 NotMounted,
276 ReservedSlug {
278 slug: String,
280 },
281 DuplicateSlug {
283 slug: String,
285 },
286 DuplicateAction {
288 name: &'static str,
290 },
291 ActionInput {
293 action: &'static str,
295 fault: ActionInputFault,
297 },
298 SignUp(SignUpFault),
300 DuplicateRelation,
302 UnregisteredRelation,
304 UnregisteredOptionSource {
307 field: String,
309 source: &'static str,
311 },
312 UnregisteredLabelSource {
314 column: String,
316 source: &'static str,
318 },
319 TraversalLens {
321 steps: usize,
323 },
324 UnresolvedLens {
326 model: &'static str,
328 steps: Vec<usize>,
330 },
331 Unbound {
333 item: &'static str,
335 },
336 TenancyColumnNotAField,
338 TenancyViaOwnColumn,
340 TenancyViaWithoutBelongsTo,
342 UnguardedForeignKey {
345 relation: String,
347 keys: Vec<String>,
349 },
350 NoColumns,
352 ZeroPageSize,
354 DuplicateColumn {
356 name: String,
358 },
359 DuplicateFilter {
361 name: String,
363 },
364 DottedFilterName {
367 name: String,
369 },
370 UnparsedFilterOption {
373 filter: String,
375 value: String,
377 },
378 DuplicateField {
380 name: String,
382 },
383 DisabledWithoutValue {
386 field: String,
388 },
389 RepeaterLeafModifier {
392 field: String,
394 leaf: String,
396 },
397 UnpostedDefault {
400 field: String,
402 value: String,
404 },
405 UnplacedWatchedField {
407 field: String,
409 },
410 HiddenWatchedField {
413 field: String,
415 },
416 UnpostedConditionValue {
419 field: String,
421 value: String,
423 },
424 MisdeclaredDependentChoice {
427 field: String,
429 },
430 MultipleDependentChoice {
433 field: String,
435 },
436 UnplacedParentField {
438 field: String,
440 parent: String,
442 },
443 RequiredConditionalField {
445 field: String,
447 },
448 CreateWithoutForm,
450 MissingControl {
454 field: String,
456 key: String,
458 },
459 FormClaimsTenantColumn {
461 field: String,
463 column: String,
465 },
466 EmptyChoice {
468 field: String,
470 },
471 CompositeKeyChoice {
473 field: String,
475 },
476 UniqueWithoutIndex {
478 field: String,
480 },
481 OptionalUnique {
483 field: String,
485 },
486 CreateColumnsNameTenant {
488 column: String,
490 },
491 UnwrittenColumn {
493 column: String,
495 },
496 ManyToMany {
498 field: String,
500 fault: ManyToManyFault,
502 },
503}
504
505impl fmt::Display for DeclarationErrorKind {
506 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
507 match self {
508 Self::MissingDb => f.write_str(
509 "the router holds no Db: install it with `.app_context(db)` before mounting the \
510 panel",
511 ),
512 Self::MissingAuthModels { models } => write!(
513 f,
514 "auth is on, but the Db does not register its shipped models ({}): register them \
515 with `toasty::models!(…, tablo_core::auth::AdminUser, \
516 tablo_core::auth::AuthSession)`, or opt out with `.auth(Auth::disabled())`",
517 models.join(", ")
518 ),
519 Self::ShellAssetsWithoutBundle => f.write_str(
520 "shell_assets need the router's asset bundle: install it with `.assets(..)` \
521 before mounting the panel",
522 ),
523 Self::FileFieldWithoutUploader { field } => write!(
524 f,
525 "file field `{field}` needs an uploader to store its bytes: install one with \
526 `Panel::uploads`"
527 ),
528 Self::InvalidSegment {
529 item,
530 segment,
531 fault,
532 } => write!(f, "{item} '{segment}' {fault}"),
533 Self::PrefixOverlapsRuntime { prefix } => write!(
534 f,
535 "prefix '{prefix}' overlaps Topcoat's runtime endpoints at '{}'",
536 crate::topcoat_compat::RUNTIME_PREFIX
537 ),
538 Self::PrefixOverlapsPanel { other } => write!(
539 f,
540 "the prefix overlaps the panel mounted at '{other}': each panel needs a prefix of \
541 its own"
542 ),
543 Self::ServeDirWithoutCatchAll { path } => write!(
544 f,
545 "serve_dir path '{path}' must end in a catch-all like '/uploads/{{*file}}'"
546 ),
547 Self::ServeDirTwice { path } => write!(f, "serve_dir path '{path}' is declared twice"),
548 Self::ServeDirTaken { path, panel } => write!(
549 f,
550 "serve_dir path '{path}' is already served by the panel mounted at '{panel}'"
551 ),
552 Self::SecondHome => {
553 f.write_str("a home page is already registered: `Panel::home` takes one")
554 }
555 Self::DuplicateResource => f.write_str(
556 "registered twice: a panel mounts each resource type once, so mount a variant \
557 under its own type",
558 ),
559 Self::DuplicatePage => f.write_str(
560 "registered twice: a panel mounts each page type once, so mount a variant under \
561 its own type",
562 ),
563 Self::NotMounted => f.write_str(
564 "not mounted on the context's panel: register it with `Panel::resource`",
565 ),
566 Self::ReservedSlug { slug } => {
567 write!(f, "slug '{slug}' names a route the panel serves itself")
568 }
569 Self::DuplicateSlug { slug } => write!(
570 f,
571 "slug '{slug}' is taken by another resource or page: each needs a distinct `slug()`"
572 ),
573 Self::DuplicateAction { name } => write!(
574 f,
575 "two actions are named '{name}': each needs a distinct `NAME`"
576 ),
577 Self::ActionInput { action, fault } => match fault {
578 ActionInputFault::ReservedField(field) => write!(
579 f,
580 "action '{action}' declares an input field named '{field}', which its POST \
581 carries itself: rename the field"
582 ),
583 ActionInputFault::FileField(field) => write!(
584 f,
585 "action '{action}' declares a file field '{field}' in its input, which an \
586 action's POST does not upload: take the file in a record form"
587 ),
588 ActionInputFault::RefusesEmpty => write!(
589 f,
590 "action '{action}' declares no input field, but its input refuses an empty \
591 submission: declare the fields it parses, or name `()`"
592 ),
593 ActionInputFault::DependentChoice(field) => write!(
594 f,
595 "action '{action}' declares a dependent choice '{field}' in its input, whose \
596 options only a resource's form refreshes: drop `depends_on`"
597 ),
598 },
599 Self::SignUp(fault) => fault.fmt(f),
600 Self::DuplicateRelation => {
601 f.write_str("declared twice: each related resource is one relation")
602 }
603 Self::UnregisteredRelation => f.write_str(
604 "the related resource is not registered on this panel: declare it with \
605 `Panel::resource`",
606 ),
607 Self::UnregisteredOptionSource { field, source } => write!(
608 f,
609 "field '{field}' takes its options from `{source}`, which this panel does not \
610 register: declare it with `Panel::resource`"
611 ),
612 Self::UnregisteredLabelSource { column, source } => write!(
613 f,
614 "column '{column}' labels its records by `{source}`, which this panel does not \
615 register: declare it with `Panel::resource`"
616 ),
617 Self::TraversalLens { steps } => write!(
618 f,
619 "a lens here names one field of its model, not a {steps}-step path"
620 ),
621 Self::UnresolvedLens { model, steps } => write!(
622 f,
623 "lens path {steps:?} resolves to no single column of `{model}`: only embedded \
624 steps (embedded structs, enum variant fields and `#[document]` fields) bind, not \
625 relation hops"
626 ),
627 Self::Unbound { item } => write!(
628 f,
629 "an embedded path into `{item}` is not bound to the app schema: a panel binds the \
630 declarations it mounts; bind one built outside a panel with `.bind(&db)`"
631 ),
632 Self::TenancyColumnNotAField => f.write_str(
633 "the `Tenancy::column` lens names no field of the model: name a field typed \
634 `TenantId`, or use `Tenancy::via` for a tenant reached through a relation",
635 ),
636 Self::TenancyViaOwnColumn => f.write_str(
637 "the `Tenancy::via` lens names one field of the model: use `Tenancy::column` for \
638 the model's own tenant column",
639 ),
640 Self::TenancyViaWithoutBelongsTo => f.write_str(
641 "the `Tenancy::via` lens does not start at a `belongs_to` relation: the tenant \
642 must be the parent's its foreign key names",
643 ),
644 Self::UnguardedForeignKey { relation, keys } => write!(
645 f,
646 "foreign key `{}` is not written through a relationship field over a \
647 tenant-scoped resource of `{relation}`'s model: the write re-checks only such a \
648 field's key, so any other could attach the row to another tenant's parent",
649 keys.join("`, `")
650 ),
651 Self::NoColumns => f.write_str(
652 "no column: declare columns with `Table::new(columns)` or in `ResourceDef::table`",
653 ),
654 Self::ZeroPageSize => f.write_str("`Table::paginate` needs a page size of at least 1"),
655 Self::DuplicateColumn { name } => write!(
656 f,
657 "two columns are named '{name}': each column needs a distinct name"
658 ),
659 Self::DuplicateFilter { name } => write!(
660 f,
661 "two filters are named '{name}': each filter needs a distinct name"
662 ),
663 Self::DottedFilterName { name } => write!(f, "filter name '{name}' holds a '.'"),
664 Self::UnparsedFilterOption { filter, value } => write!(
665 f,
666 "filter '{filter}' offers '{value}', which its field's type does not parse: spell \
667 each option as the field's form value, or take the type's own with \
668 `SelectFilter::of`"
669 ),
670 Self::DuplicateField { name } => write!(
671 f,
672 "two fields are named '{name}': each input needs a distinct field"
673 ),
674 Self::DisabledWithoutValue { field } => write!(
675 f,
676 "field '{field}' is disabled and required, so no create can fill it: give it a \
677 `default` (a file field takes none), make it optional, or use `disabled_on_edit`"
678 ),
679 Self::RepeaterLeafModifier { field, leaf } => write!(
680 f,
681 "repeater '{field}' disables its row's '{leaf}' or gives it a default, which a \
682 row does not apply: disable the repeater's own field instead"
683 ),
684 Self::UnpostedDefault { field, value } => write!(
685 f,
686 "field '{field}' defaults to '{value}', which its control never posts: spell the \
687 default as the field's form value, a list for a multiple choice"
688 ),
689 Self::UnplacedWatchedField { field } => write!(
690 f,
691 "a condition watches field '{field}', which the schema does not place: place it \
692 in the same schema"
693 ),
694 Self::HiddenWatchedField { field } => write!(
695 f,
696 "a condition watches field '{field}', which a condition hides while the guarded \
697 field or block still shows: place the guarded one inside the block that hides \
698 '{field}'"
699 ),
700 Self::UnpostedConditionValue { field, value } => write!(
701 f,
702 "a condition shows a field while '{field}' posts '{value}', which it never posts: \
703 name one of its option values, or `true` or `false` for a toggle"
704 ),
705 Self::MisdeclaredDependentChoice { field } => write!(
706 f,
707 "choice '{field}' depends on a column its options do not have: call \
708 `relationship::<R>()` and name a column of `R`'s model in `depends_on`"
709 ),
710 Self::MultipleDependentChoice { field } => write!(
711 f,
712 "choice '{field}' depends on another field through a multiple choice, which \
713 neither narrows another choice nor is narrowed: drop `depends_on` or `multiple`"
714 ),
715 Self::UnplacedParentField { field, parent } => write!(
716 f,
717 "choice '{field}' depends on field '{parent}', which the schema does not place: \
718 place it in the same schema"
719 ),
720 Self::RequiredConditionalField { field } => write!(
721 f,
722 "field '{field}' is conditional but has no blank answer: a submission that hides \
723 it posts nothing, so make it an `Option` or give it `#[form(blank = ..)]`"
724 ),
725 Self::CreateWithoutForm => f.write_str(
726 "the policy allows create, but there is no form: name the record form in `type \
727 Form`",
728 ),
729 Self::MissingControl { field, key } => write!(
730 f,
731 "record-form field `{field}` binds key `{key}`, but no control posts it: \
732 `RecordForm::control` must render the field's control"
733 ),
734 Self::FormClaimsTenantColumn { field, column } => write!(
735 f,
736 "record-form field `{field}` claims the tenant column `{column}`, which the \
737 framework stamps on create: drop it from the form"
738 ),
739 Self::EmptyChoice { field } => write!(
740 f,
741 "choice field `{field}` declares no options and no relationship, so it offers \
742 nothing to choose: add `.options(..)` or `.relationship::<Source>()`, or \
743 `#[form(relationship = Source)]` on a record form's field"
744 ),
745 Self::CompositeKeyChoice { field } => write!(
746 f,
747 "`{field}` reads a relationship whose model has a composite primary key, which \
748 no option value can spell"
749 ),
750 Self::UniqueWithoutIndex { field } => write!(
751 f,
752 "field `{field}` is marked unique, but no unique index covers its column: add \
753 `#[unique]` (or `#[unique(..)]`) to the model or drop `.unique()`, which would \
754 otherwise check a rule the database does not enforce"
755 ),
756 Self::OptionalUnique { field } => write!(
757 f,
758 "field `{field}` is marked unique, but its record-form field answers an empty \
759 submission and its column is not nullable, so every empty submission stores \
760 the same value: make the field required, or an `Option`"
761 ),
762 Self::CreateColumnsNameTenant { column } => write!(
763 f,
764 "`create_column` names the tenant column `{column}`, which the framework stamps \
765 on create: drop it there and delegate to `write_create`"
766 ),
767 Self::UnwrittenColumn { column } => write!(
768 f,
769 "the policy allows create, but nothing writes the non-nullable column `{column}`: \
770 the record form has no such field, toasty fills no `#[default(..)]` for it, and \
771 no `create_column` names it, so every create would fail at the driver"
772 ),
773 Self::ManyToMany { field, fault } => match fault {
774 ManyToManyFault::NotJoinModel => write!(
775 f,
776 "`{field}` is no many-to-many field: declare it \
777 `#[has_many(via = joins.target)]`, through a `has_many` to a join model and \
778 the join model's `belongs_to` to the target"
779 ),
780 ManyToManyFault::CompositeKey => write!(
781 f,
782 "`{field}`'s join model holds a key of several columns, or one referencing no \
783 single-column primary key: a link writes one column per side"
784 ),
785 ManyToManyFault::UnwritableColumn { model, column } => write!(
786 f,
787 "`{field}`'s join model `{model}` has a column `{column}` a new link cannot \
788 fill: make it nullable or `#[auto]`, or link the records in your own record fn"
789 ),
790 ManyToManyFault::NotMultipleChoice => write!(
791 f,
792 "`{field}` is a many-to-many field, so its control is a multiple choice over \
793 a relationship: `.choice().relationship::<R>().multiple()`"
794 ),
795 ManyToManyFault::OtherSource => write!(
796 f,
797 "`{field}` offers another model's records than the ones its join model links"
798 ),
799 ManyToManyFault::NoJoin => write!(
800 f,
801 "multiple choice `{field}` names no many-to-many field of the model, so \
802 nothing would store its records: bind it to a `#[has_many(via = ..)]` field"
803 ),
804 },
805 }
806 }
807}
808
809#[derive(Debug, Clone, Copy, PartialEq, Eq)]
811#[non_exhaustive]
812pub enum SegmentFault {
813 Empty,
815 Dot,
817 Char(char),
819}
820
821impl fmt::Display for SegmentFault {
822 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
823 match self {
824 Self::Empty => f.write_str("is empty"),
825 Self::Dot => f.write_str("is '.' or '..'"),
826 Self::Char(c) => write!(
827 f,
828 "contains {c:?}: quotes, backslashes, control characters, whitespace, URL \
829 punctuation and the route pattern characters '{{', '}}', '(' and ')' are refused"
830 ),
831 }
832 }
833}
834
835pub(crate) const fn segment_fault(segment: &str) -> Option<SegmentFault> {
839 let bytes = segment.as_bytes();
840 if bytes.is_empty() {
841 return Some(SegmentFault::Empty);
842 }
843 if matches!(bytes, b"." | b"..") {
844 return Some(SegmentFault::Dot);
845 }
846 let mut at = 0;
847 while at < bytes.len() {
848 let (c, width) = decode_char(bytes, at);
849 if c.is_control()
850 || c.is_whitespace()
851 || matches!(
852 c,
853 '"' | '\\' | '/' | '?' | '#' | '%' | '&' | '=' | '{' | '}' | '(' | ')'
854 )
855 {
856 return Some(SegmentFault::Char(c));
857 }
858 at += width;
859 }
860 None
861}
862
863const fn decode_char(bytes: &[u8], at: usize) -> (char, usize) {
865 let lead = bytes[at] as u32;
866 let (code, width) = if lead < 0x80 {
867 (lead, 1)
868 } else if lead < 0xE0 {
869 ((lead & 0x1F) << 6 | tail(bytes, at + 1), 2)
870 } else if lead < 0xF0 {
871 (
872 (lead & 0x0F) << 12 | tail(bytes, at + 1) << 6 | tail(bytes, at + 2),
873 3,
874 )
875 } else {
876 (
877 (lead & 0x07) << 18
878 | tail(bytes, at + 1) << 12
879 | tail(bytes, at + 2) << 6
880 | tail(bytes, at + 3),
881 4,
882 )
883 };
884 match char::from_u32(code) {
885 Some(c) => (c, width),
886 None => panic!("a str holds valid UTF-8"),
887 }
888}
889
890const fn tail(bytes: &[u8], at: usize) -> u32 {
892 bytes[at] as u32 & 0x3F
893}
894
895#[cfg(test)]
896mod tests;