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)]
123#[non_exhaustive]
124pub enum DeclarationErrorKind {
125 MissingDb,
127 MissingAuthModels {
129 models: Vec<&'static str>,
131 },
132 ShellAssetsWithoutBundle,
134 InvalidSegment {
136 item: &'static str,
138 segment: String,
140 fault: SegmentFault,
142 },
143 PrefixOverlapsRuntime {
145 prefix: String,
147 },
148 PrefixOverlapsPanel {
150 other: String,
152 },
153 ServeDirWithoutCatchAll {
155 path: String,
157 },
158 ServeDirTwice {
160 path: String,
162 },
163 ServeDirTaken {
165 path: String,
167 panel: String,
169 },
170 SecondHome,
172 DuplicateResource,
174 NotMounted,
176 ReservedSlug {
178 slug: String,
180 },
181 DuplicateSlug {
183 slug: String,
185 },
186 DuplicateAction {
188 name: &'static str,
190 },
191 DuplicateRelation,
193 UnregisteredRelation,
195 UnregisteredOptionSource {
197 field: String,
199 source: &'static str,
201 },
202 TraversalLens {
204 steps: usize,
206 },
207 UnresolvedLens {
209 model: &'static str,
211 steps: Vec<usize>,
213 },
214 Unbound {
216 item: &'static str,
218 },
219 TenancyColumnNotAField,
221 TenancyViaOwnColumn,
223 TenancyViaWithoutBelongsTo,
225 UnguardedForeignKey {
228 relation: String,
230 keys: Vec<String>,
232 },
233 NoColumns,
235 ZeroPageSize,
237 DuplicateColumn {
239 name: String,
241 },
242 DuplicateFilter {
244 name: String,
246 },
247 DuplicateField {
249 name: String,
251 },
252 CreateWithoutForm,
254 UnboundControl {
256 control: String,
258 },
259 MissingControl {
261 field: String,
263 key: String,
265 },
266 FormClaimsTenantColumn {
268 field: String,
270 column: String,
272 },
273 CompositeKeyChoice {
275 field: String,
277 },
278 UniqueWithoutIndex {
280 field: String,
282 },
283 OptionalUnique {
285 field: String,
287 },
288 CreateColumnsNameTenant {
290 column: String,
292 },
293 UnknownCreateColumn {
295 column: &'static str,
297 },
298 UnwrittenColumn {
300 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487#[non_exhaustive]
488pub enum SegmentFault {
489 Empty,
491 Dot,
493 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
511pub(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
539const 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
566const fn tail(bytes: &[u8], at: usize) -> u32 {
568 bytes[at] as u32 & 0x3F
569}
570
571#[cfg(test)]
572mod tests;