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::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#[derive(Debug, Clone, PartialEq, Eq)]
101#[non_exhaustive]
102pub enum Site {
103 Panel,
105 Registration,
108 Tenancy,
110 Table,
112 Form,
114 View,
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 NotMounted,
274 ReservedSlug {
276 slug: String,
278 },
279 DuplicateSlug {
281 slug: String,
283 },
284 DuplicateAction {
286 name: &'static str,
288 },
289 ActionInput {
291 action: &'static str,
293 fault: ActionInputFault,
295 },
296 SignUp(SignUpFault),
298 DuplicateRelation,
300 UnregisteredRelation,
302 UnregisteredOptionSource {
304 field: String,
306 source: &'static str,
308 },
309 UnregisteredLabelSource {
311 column: String,
313 source: &'static str,
315 },
316 TraversalLens {
318 steps: usize,
320 },
321 UnresolvedLens {
323 model: &'static str,
325 steps: Vec<usize>,
327 },
328 Unbound {
330 item: &'static str,
332 },
333 TenancyColumnNotAField,
335 TenancyViaOwnColumn,
337 TenancyViaWithoutBelongsTo,
339 UnguardedForeignKey {
342 relation: String,
344 keys: Vec<String>,
346 },
347 NoColumns,
349 ZeroPageSize,
351 DuplicateColumn {
353 name: String,
355 },
356 DuplicateFilter {
358 name: String,
360 },
361 UnparsedFilterOption {
364 filter: String,
366 value: String,
368 },
369 DuplicateField {
371 name: String,
373 },
374 UnplacedWatchedField {
376 field: String,
378 },
379 HiddenWatchedField {
382 field: String,
384 },
385 UnpostedConditionValue {
388 field: String,
390 value: String,
392 },
393 MisdeclaredDependentChoice {
396 field: String,
398 },
399 MultipleDependentChoice {
402 field: String,
404 },
405 UnplacedParentField {
407 field: String,
409 parent: String,
411 },
412 RequiredConditionalField {
414 field: String,
416 },
417 CreateWithoutForm,
419 MissingControl {
423 field: String,
425 key: String,
427 },
428 FormClaimsTenantColumn {
430 field: String,
432 column: String,
434 },
435 EmptyChoice {
437 field: String,
439 },
440 CompositeKeyChoice {
442 field: String,
444 },
445 UniqueWithoutIndex {
447 field: String,
449 },
450 OptionalUnique {
452 field: String,
454 },
455 CreateColumnsNameTenant {
457 column: String,
459 },
460 UnwrittenColumn {
462 column: String,
464 },
465 ManyToMany {
467 field: String,
469 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
760#[non_exhaustive]
761pub enum SegmentFault {
762 Empty,
764 Dot,
766 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
784pub(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
812const 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
839const fn tail(bytes: &[u8], at: usize) -> u32 {
841 bytes[at] as u32 & 0x3F
842}
843
844#[cfg(test)]
845mod tests;