1use 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
18const 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
304fn 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
344fn 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
373fn 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}