Skip to main content

tablo_core/declaration/
wording.rs

1//! The wording of each [`DeclarationErrorKind`].
2
3use std::fmt;
4
5use super::{
6    DeclarationError, DeclarationErrorKind, fmt_action_input, fmt_field_rule, fmt_many_to_many,
7};
8
9impl fmt::Display for DeclarationError {
10    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
11        if let Some(resource) = self.resource {
12            write!(f, "`{resource}`{}: ", self.site)?;
13        }
14        write!(f, "{}", self.kind)
15    }
16}
17
18/// [`DeclarationErrorKind::EmptyDetail`]'s wording.
19const EMPTY_DETAIL: &str = "the view page shows a detail listing no column: declare a `detail` or \
20                            a table, or turn the page off with `without_page(ResourcePage::View)`";
21
22impl fmt::Display for DeclarationErrorKind {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::MissingDb => f.write_str(
26                "the router holds no Db: install it with `.with(db)` before mounting the \
27                 panel",
28            ),
29            Self::MissingAuthModels { models } => write!(
30                f,
31                "auth is on, but the Db does not register its shipped models ({models}): build \
32                 the Db from `tablo::models!(…)`, which registers them, or opt out with \
33                 `.auth(Auth::disabled())`",
34                models = models.join(", ")
35            ),
36            Self::ShellAssetsWithoutBundle => f.write_str(
37                "shell_assets need the router's asset bundle: install it with `.assets(..)` \
38                 before mounting the panel",
39            ),
40            Self::NotificationsWithoutAuth => f.write_str(
41                "notifications are for signed-in users: `Panel::notifications` needs auth on",
42            ),
43            Self::MissingAuditModel => f.write_str(
44                "`Panel::audit_log` stores its entries in `AuditEntry`, which the Db does not \
45                 register: build the Db from `tablo::models!(…)`, or add `AuditEntry` to \
46                 `toasty::models!(…)`",
47            ),
48            Self::BrandColor { value } => write!(
49                f,
50                "the brand color `{value}` is no CSS color: spell it `#4f46e5` or \
51                 `oklch(0.55 0.2 260)`",
52            ),
53            Self::ExternalAuthFeature { feature } => write!(
54                f,
55                "`Auth::external` leaves credentials to the host app: drop `{feature}`",
56            ),
57            Self::FileFieldWithoutUploader { field } => write!(
58                f,
59                "file field `{field}` needs an uploader to store its bytes: install one with \
60                 `Panel::uploads`"
61            ),
62            Self::InvalidSegment { .. }
63            | Self::PrefixOverlapsRuntime { .. }
64            | Self::PrefixOverlapsPanel { .. }
65            | Self::EndpointOutsideRuntime { .. }
66            | Self::ServeDirWithoutCatchAll { .. }
67            | Self::ServeDirTwice { .. }
68            | Self::ServeDirTaken { .. } => fmt_route(f, self),
69            Self::ContentSecurityPolicy(fault) => fault.fmt(f),
70            Self::SecondHome => {
71                f.write_str("a home page is already registered: `Panel::home` takes one")
72            }
73            Self::NoRoot => f.write_str(
74                "every resource is hidden from navigation and no home page is registered, so the \
75                 panel's root, where a sign-in lands, leads nowhere: show one resource in the \
76                 sidebar, or register a page with `Panel::home`",
77            ),
78            Self::DuplicateResource => f.write_str(
79                "registered twice: a panel mounts each resource type once, so mount a variant \
80                 under its own type",
81            ),
82            Self::DuplicatePage => f.write_str(
83                "registered twice: a panel mounts each page type once, so mount a variant under \
84                 its own type",
85            ),
86            Self::NotMounted => f.write_str(
87                "not mounted on the context's panel: register it with `Panel::resource`",
88            ),
89            Self::ReservedSlug { slug } => {
90                write!(f, "slug '{slug}' names a route the panel serves itself")
91            }
92            Self::DuplicateSlug { slug } => write!(
93                f,
94                "slug '{slug}' is taken by another resource or page: each needs a distinct `slug()`"
95            ),
96            Self::DuplicateAction { name } => write!(
97                f,
98                "two actions are named '{name}': each needs a distinct `NAME`"
99            ),
100            Self::DuplicateWidget { name } => write!(
101                f,
102                "two widgets are named '{name}': each needs a distinct `WidgetDef::name`"
103            ),
104            Self::MisdeclaredPageFilter { key } => write!(
105                f,
106                "page filter '{key}' shares its key with another, or its key is not ASCII \
107                 letters, digits, `-` and `_`: give each filter a distinct key of those"
108            ),
109            Self::EditableColumn { column, fault } => fault.fmt(f, column),
110            Self::ActionInput { action, fault } => fmt_action_input(f, action, fault),
111            Self::SignUp(fault) => fault.fmt(f),
112            Self::Profile(fault) => fault.fmt(f),
113            Self::Area(fault) => fault.fmt(f),
114            Self::PasswordReset(fault) => fault.fmt(f),
115            Self::EmailVerification(fault) => fault.fmt(f),
116            Self::TenantRegistration(fault) => fault.fmt(f),
117            Self::TenantProfile(fault) => fault.fmt(f),
118            Self::TwoFactor(fault) => fault.fmt(f),
119            Self::Oidc(fault) => fault.fmt(f),
120            Self::AuthMail(fault) => fault.fmt(f),
121            Self::Locale(fault) => fault.fmt(f),
122            Self::DuplicateRelation => {
123                f.write_str("declared twice: each related resource is one relation")
124            }
125            Self::HasOneTab => f.write_str(
126                "a `has_one` relation cannot be a tab: its form edits inside the record's own \
127                 form",
128            ),
129            Self::UnregisteredRelation => f.write_str(
130                "the related resource is not registered on this panel: declare it with \
131                 `Panel::resource`",
132            ),
133            Self::UnregisteredOptionSource { field, source } => write!(
134                f,
135                "field '{field}' takes its options from `{source}`, which this panel does not \
136                 register: declare it with `Panel::resource`"
137            ),
138            Self::UnregisteredLabelSource { column, source } => write!(
139                f,
140                "column '{column}' labels its records by `{source}`, which this panel does not \
141                 register: declare it with `Panel::resource`"
142            ),
143            Self::TraversalLens { steps } => write!(
144                f,
145                "a lens here names one field of its model, not a {steps}-step path"
146            ),
147            Self::UnresolvedLens { model, steps } => write!(
148                f,
149                "lens path {steps:?} resolves to no single column of `{model}`: only embedded \
150                 steps (embedded structs, enum variant fields and `#[document]` fields) bind, not \
151                 relation hops"
152            ),
153            Self::Uncompiled { model, error } => write!(
154                f,
155                "`{model}` compiles to no schema, so its embedded paths name no column: {error}"
156            ),
157            Self::Tenancy { fault } => fault.fmt(f),
158            Self::FieldAccess { fault } => fault.fmt(f),
159            Self::NoColumns => f.write_str(
160                "no column: declare columns with `Table::new(columns)` or in `ResourceDef::table`",
161            ),
162            Self::ZeroPageSize => f.write_str("`Table::page_size` needs a page size of at least 1"),
163            Self::UnsortableDefaultSort { column } => write!(
164                f,
165                "`Table::default_sort_by` sorts on '{column}', but the table declares no sortable column for it"
166            ),
167            Self::DuplicateColumn { name } => write!(
168                f,
169                "two columns are named '{name}': each column needs a distinct name"
170            ),
171            Self::DuplicateFilter { name } => write!(
172                f,
173                "two filters are named '{name}': each filter needs a distinct name"
174            ),
175            Self::DuplicateTab { key } => write!(
176                f,
177                "two list tabs share the key '{key}': each tab needs a distinct key"
178            ),
179            Self::DuplicateGrouping { name } => write!(
180                f,
181                "the table groups by '{name}' twice: declare each grouping once"
182            ),
183            Self::DottedFilterName { name } => write!(f, "filter name '{name}' holds a '.'"),
184            Self::UnparsedFilterOption { filter, value } => write!(
185                f,
186                "filter '{filter}' offers '{value}', which its field's type does not parse: spell \
187                 each option as the field's form value, or take the type's own with \
188                 `SelectFilter::of`"
189            ),
190            Self::UnparsedTabOption { value } => write!(
191                f,
192                "the option tabs offer '{value}', which the field's type does not parse: spell \
193                 each option as the field's form value"
194            ),
195            Self::DuplicateField { name } => write!(
196                f,
197                "two fields are named '{name}': each input needs a distinct field"
198            ),
199            Self::DisabledWithoutValue { field } => write!(
200                f,
201                "field '{field}' is disabled or hidden on the create form, or a condition can \
202                 disable it, and it is required, so a create cannot fill it: give it a `default` \
203                 (a file field takes none), make it optional, or use `disabled_on_edit`"
204            ),
205            Self::RepeaterLeafModifier { field, leaf } => write!(
206                f,
207                "repeater '{field}' disables its row's '{leaf}' or gives it a default, which a \
208                 row does not apply: disable the repeater's own field instead"
209            ),
210            Self::RepeaterPassword { field, leaf } => write!(
211                f,
212                "repeater '{field}' rows hold password '{leaf}', which an edit never renders \
213                 back, so each save overwrites it: keep the secret out of the item"
214            ),
215            Self::FieldRule { field, fault } => fmt_field_rule(f, field, fault),
216            Self::UnpostedDefault { field, value } => write!(
217                f,
218                "field '{field}' defaults to '{value}', which its control never posts: spell the \
219                 default as the field's form value, a list for a multiple choice"
220            ),
221            Self::UnplacedWatchedField { .. }
222            | Self::HiddenWatchedField { .. }
223            | Self::DisabledWatchedField { .. }
224            | Self::UnpostedConditionValue { .. } => fmt_condition(f, self),
225            Self::MisdeclaredDependentChoice { .. }
226            | Self::UnfilterableChoice { .. }
227            | Self::MultipleDependentChoice { .. }
228            | Self::ListedDependentChoice { .. }
229            | Self::UncreatableChoice { .. }
230            | Self::UnplacedParentField { .. } => fmt_choice(f, self),
231            Self::RequiredConditionalField { field } => write!(
232                f,
233                "field '{field}' is conditional but has no blank answer: a submission that hides \
234                 it posts nothing, so make it an `Option` or give it `#[form(blank = ..)]`"
235            ),
236            Self::MissingControl { field, key } => write!(
237                f,
238                "record-form field `{field}` binds key `{key}`, but no control posts it: \
239                 `RecordForm::control` must render the field's control"
240            ),
241            Self::FormClaimsTenantColumn { field, column } => write!(
242                f,
243                "record-form field `{field}` claims the tenant column `{column}`, which the \
244                 framework stamps on create: drop it from the form"
245            ),
246            Self::FormClaimsSoftDeleteColumn { field, column } => write!(
247                f,
248                "record-form field `{field}` claims the soft-delete column `{column}`, which only \
249                 a delete and a restore write: drop it from the form, or mark it \
250                 `#[form(skip)]` on a `ModelForm`"
251            ),
252            Self::ReservedFormField { field } => write!(
253                f,
254                "form field `{field}` takes a name the form's POST carries itself, so the two \
255                 would read each other's value: rename the field"
256            ),
257            Self::EmptyChoice { field } => write!(
258                f,
259                "choice field `{field}` declares no options and no relationship, so it offers \
260                 nothing to choose: add `.options(..)` or `.relationship::<Source>()`, or \
261                 `#[form(relationship = Source)]` on a record form's field"
262            ),
263            Self::CompositeKeyChoice { field } => write!(
264                f,
265                "`{field}` reads a relationship whose model has a composite primary key, which \
266                 no option value can spell"
267            ),
268            Self::UniqueWithoutIndex { field } => write!(
269                f,
270                "field `{field}` is marked unique, but no unique index covers its column: add \
271                 `#[unique]` (or `#[unique(..)]`) to the model or drop `.unique()`, which would \
272                 otherwise check a rule the database does not enforce"
273            ),
274            Self::OptionalUnique { field } => write!(
275                f,
276                "field `{field}` is marked unique, but its record-form field answers an empty \
277                 submission and its column is not nullable, so every empty submission stores \
278                 the same value: make the field required, or an `Option`"
279            ),
280            Self::CreateColumnsNameTenant { column } => write!(
281                f,
282                "`create_column` names the tenant column `{column}`, which the framework stamps \
283                 on create: drop it there and delegate to `write_create`"
284            ),
285            Self::UnwrittenColumn { column } => write!(
286                f,
287                "nothing writes the non-nullable column `{column}` on create: the record form has \
288                 no such field, toasty fills no `#[default(..)]` for it, and no `create_column` \
289                 names it, so every create would fail at the driver; a resource that creates \
290                 nothing declares `without_page(ResourcePage::Create)`"
291            ),
292            Self::ImportWithoutCreate => write!(
293                f,
294                "`importable` creates each row through the create form, which this resource does \
295                 not serve: it has no record form, or declares `without_page(ResourcePage::Create)`"
296            ),
297            Self::EmptyDetail => f.write_str(EMPTY_DETAIL),
298            Self::RelatedForm(fault) => fault.fmt(f),
299            Self::ManyToMany { field, fault } => fmt_many_to_many(f, field, fault),
300        }
301    }
302}
303
304/// The wording of a choice field's [`DeclarationErrorKind`].
305fn fmt_choice(f: &mut fmt::Formatter<'_>, kind: &DeclarationErrorKind) -> fmt::Result {
306    match kind {
307        DeclarationErrorKind::MisdeclaredDependentChoice { field } => write!(
308            f,
309            "choice '{field}' depends on a column its options do not have: call \
310             `relationship::<R>()` and name a column of `R`'s model in `depends_on`"
311        ),
312        DeclarationErrorKind::UnfilterableChoice { field } => write!(
313            f,
314            "choice '{field}' filters its options but loads none from a relationship: call \
315             `relationship::<R>()` or drop `options_where`"
316        ),
317        DeclarationErrorKind::MultipleDependentChoice { field } => write!(
318            f,
319            "choice '{field}' depends on another field through a multiple choice, which \
320             neither narrows another choice nor is narrowed: drop `depends_on` or `multiple`"
321        ),
322        DeclarationErrorKind::ListedDependentChoice { field } => write!(
323            f,
324            "choice '{field}' depends on another field but renders its options as radios or \
325             buttons, which the browser cannot narrow: drop `depends_on`, or `radio` or \
326             `buttons`"
327        ),
328        DeclarationErrorKind::UncreatableChoice { field } => write!(
329            f,
330            "choice '{field}' is creatable but is not a single select over a resource's \
331             records: drop `creatable`, or declare a single select with \
332             `relationship::<R>()` over a resource `R`, outside a repeater and without \
333             `disabled_when`"
334        ),
335        DeclarationErrorKind::UnplacedParentField { field, parent } => write!(
336            f,
337            "choice '{field}' depends on field '{parent}', which the schema does not place: \
338             place it in the same schema"
339        ),
340        _ => unreachable!("not a choice's error"),
341    }
342}
343
344/// The wording of a condition's [`DeclarationErrorKind`].
345fn fmt_condition(f: &mut fmt::Formatter<'_>, kind: &DeclarationErrorKind) -> fmt::Result {
346    match kind {
347        DeclarationErrorKind::UnplacedWatchedField { field } => write!(
348            f,
349            "a condition watches field '{field}', which the schema does not place: place it \
350             in the same schema"
351        ),
352        DeclarationErrorKind::HiddenWatchedField { field } => write!(
353            f,
354            "a condition watches field '{field}', which a condition hides while the guarded \
355             field or block still shows: place the guarded one inside the block that hides \
356             '{field}'"
357        ),
358        DeclarationErrorKind::DisabledWatchedField { field } => write!(
359            f,
360            "a condition or a dependent choice watches field '{field}', which a condition \
361             disables and so posts nothing: watch another field, or drop `disabled_when` from \
362             '{field}'"
363        ),
364        DeclarationErrorKind::UnpostedConditionValue { field, value } => write!(
365            f,
366            "a condition shows a field while '{field}' posts '{value}', which it never posts: \
367             name one of its option values, or `true` or `false` for a toggle"
368        ),
369        _ => unreachable!("not a condition's error"),
370    }
371}
372
373/// The wording of a route's or a prefix's [`DeclarationErrorKind`].
374fn fmt_route(f: &mut fmt::Formatter<'_>, kind: &DeclarationErrorKind) -> fmt::Result {
375    match kind {
376        DeclarationErrorKind::InvalidSegment {
377            item,
378            segment,
379            fault,
380        } => write!(f, "{item} '{segment}' {fault}"),
381        DeclarationErrorKind::PrefixOverlapsRuntime { prefix } => write!(
382            f,
383            "prefix '{prefix}' overlaps Topcoat's runtime endpoints at '{}'",
384            crate::topcoat_compat::RUNTIME_PREFIX
385        ),
386        DeclarationErrorKind::PrefixOverlapsPanel { other } => write!(
387            f,
388            "the prefix overlaps the panel mounted at '{other}': each panel needs a prefix of \
389             its own"
390        ),
391        DeclarationErrorKind::EndpointOutsideRuntime { path } => write!(
392            f,
393            "endpoint '{path}' is not a shard or procedure under '{}': the panel's prefix or \
394             the app's own layers guard a route elsewhere",
395            crate::topcoat_compat::RUNTIME_PREFIX
396        ),
397        DeclarationErrorKind::ServeDirWithoutCatchAll { path } => write!(
398            f,
399            "serve_dir path '{path}' must end in a catch-all like '/uploads/{{*file}}'"
400        ),
401        DeclarationErrorKind::ServeDirTwice { path } => {
402            write!(f, "serve_dir path '{path}' is declared twice")
403        }
404        DeclarationErrorKind::ServeDirTaken { path, panel } => write!(
405            f,
406            "serve_dir path '{path}' is already served by the panel mounted at '{panel}'"
407        ),
408        _ => unreachable!("not a route's error"),
409    }
410}