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 UnwrittenColumn {
295 column: String,
297 },
298}
299
300impl fmt::Display for DeclarationErrorKind {
301 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302 match self {
303 Self::MissingDb => f.write_str(
304 "the router holds no Db: install it with `.app_context(db)` before mounting the \
305 panel",
306 ),
307 Self::MissingAuthModels { models } => write!(
308 f,
309 "auth is on, but the Db does not register its shipped models ({}): register them \
310 with `toasty::models!(…, tablo_core::auth::AdminUser, \
311 tablo_core::auth::AuthSession)`, or opt out with `.auth(Auth::disabled())`",
312 models.join(", ")
313 ),
314 Self::ShellAssetsWithoutBundle => f.write_str(
315 "shell_assets need the router's asset bundle: install it with `.assets(..)` \
316 before mounting the panel",
317 ),
318 Self::InvalidSegment {
319 item,
320 segment,
321 fault,
322 } => write!(f, "{item} '{segment}' {fault}"),
323 Self::PrefixOverlapsRuntime { prefix } => write!(
324 f,
325 "prefix '{prefix}' overlaps Topcoat's runtime endpoints at '{}'",
326 crate::topcoat_compat::RUNTIME_PREFIX
327 ),
328 Self::PrefixOverlapsPanel { other } => write!(
329 f,
330 "the prefix overlaps the panel mounted at '{other}': each panel needs a prefix of \
331 its own"
332 ),
333 Self::ServeDirWithoutCatchAll { path } => write!(
334 f,
335 "serve_dir path '{path}' must end in a catch-all like '/uploads/{{*file}}'"
336 ),
337 Self::ServeDirTwice { path } => write!(f, "serve_dir path '{path}' is declared twice"),
338 Self::ServeDirTaken { path, panel } => write!(
339 f,
340 "serve_dir path '{path}' is already served by the panel mounted at '{panel}'"
341 ),
342 Self::SecondHome => {
343 f.write_str("a home page is already registered: `Panel::home` takes one")
344 }
345 Self::DuplicateResource => f.write_str(
346 "registered twice: a panel mounts each resource type once, so mount a variant \
347 under its own type",
348 ),
349 Self::NotMounted => f.write_str(
350 "not mounted on the context's panel: register it with `Panel::resource`",
351 ),
352 Self::ReservedSlug { slug } => {
353 write!(f, "slug '{slug}' names a route the panel serves itself")
354 }
355 Self::DuplicateSlug { slug } => write!(
356 f,
357 "slug '{slug}' is taken by another resource or page: each needs a distinct `slug()`"
358 ),
359 Self::DuplicateAction { name } => write!(
360 f,
361 "two actions are named '{name}': each needs a distinct `NAME`"
362 ),
363 Self::DuplicateRelation => {
364 f.write_str("declared twice: each related resource is one relation")
365 }
366 Self::UnregisteredRelation => f.write_str(
367 "the related resource is not registered on this panel: declare it with \
368 `Panel::resource`",
369 ),
370 Self::UnregisteredOptionSource { field, source } => write!(
371 f,
372 "field '{field}' takes its options from `{source}`, which this panel does not \
373 register: declare it with `Panel::resource`"
374 ),
375 Self::TraversalLens { steps } => write!(
376 f,
377 "a lens here names one field of its model, not a {steps}-step path"
378 ),
379 Self::UnresolvedLens { model, steps } => write!(
380 f,
381 "lens path {steps:?} resolves to no single column of `{model}`: only embedded \
382 steps (embedded structs, enum variant fields and `#[document]` fields) bind, not \
383 relation hops"
384 ),
385 Self::Unbound { item } => write!(
386 f,
387 "an embedded path into `{item}` is not bound to the app schema: a panel binds the \
388 declarations it mounts; bind one built outside a panel with `.bind(&db)`"
389 ),
390 Self::TenancyColumnNotAField => f.write_str(
391 "the `Tenancy::column` lens names no field of the model: name a field typed \
392 `TenantId`, or use `Tenancy::via` for a tenant reached through a relation",
393 ),
394 Self::TenancyViaOwnColumn => f.write_str(
395 "the `Tenancy::via` lens names one field of the model: use `Tenancy::column` for \
396 the model's own tenant column",
397 ),
398 Self::TenancyViaWithoutBelongsTo => f.write_str(
399 "the `Tenancy::via` lens does not start at a `belongs_to` relation: the tenant \
400 must be the parent's its foreign key names",
401 ),
402 Self::UnguardedForeignKey { relation, keys } => write!(
403 f,
404 "foreign key `{}` is not written through a relationship field over a \
405 tenant-scoped resource of `{relation}`'s model: the write re-checks only such a \
406 field's key, so any other could attach the row to another tenant's parent",
407 keys.join("`, `")
408 ),
409 Self::NoColumns => f.write_str(
410 "no column: declare columns with `Table::new(columns)` or in `ResourceDef::table`",
411 ),
412 Self::ZeroPageSize => f.write_str("`Table::paginate` needs a page size of at least 1"),
413 Self::DuplicateColumn { name } => write!(
414 f,
415 "two columns are named '{name}': each column needs a distinct name"
416 ),
417 Self::DuplicateFilter { name } => write!(
418 f,
419 "two filters are named '{name}': each filter needs a distinct name"
420 ),
421 Self::DuplicateField { name } => write!(
422 f,
423 "two fields are named '{name}': each input needs a distinct field"
424 ),
425 Self::CreateWithoutForm => f.write_str(
426 "the policy allows create, but there is no form: name the record form in `type \
427 Form`",
428 ),
429 Self::UnboundControl { control } => write!(
430 f,
431 "control `{control}` is bound by no record-form field, so what the user types \
432 there is never written: a form places its record form's own `controls()`"
433 ),
434 Self::MissingControl { field, key } => write!(
435 f,
436 "record-form field `{field}` binds key `{key}`, but no control declares it: place \
437 its control in the form"
438 ),
439 Self::FormClaimsTenantColumn { field, column } => write!(
440 f,
441 "record-form field `{field}` claims the tenant column `{column}`, which the \
442 framework stamps on create: drop it from the form"
443 ),
444 Self::CompositeKeyChoice { field } => write!(
445 f,
446 "relationship field `{field}` loads a model with a composite primary key, which \
447 no option value can spell"
448 ),
449 Self::UniqueWithoutIndex { field } => write!(
450 f,
451 "field `{field}` is marked unique, but no unique index covers its column: add \
452 `#[unique]` (or `#[unique(..)]`) to the model or drop `.unique()`, which would \
453 otherwise check a rule the database does not enforce"
454 ),
455 Self::OptionalUnique { field } => write!(
456 f,
457 "field `{field}` is marked unique, but its record-form field answers an empty \
458 submission and its column is not nullable, so every empty submission stores \
459 the same value: make the field required, or an `Option`"
460 ),
461 Self::CreateColumnsNameTenant { column } => write!(
462 f,
463 "`create_column` names the tenant column `{column}`, which the framework stamps \
464 on create: drop it there and delegate to `write_create`"
465 ),
466 Self::UnwrittenColumn { column } => write!(
467 f,
468 "the policy allows create, but nothing writes the non-nullable column `{column}`: \
469 the record form has no such field, toasty fills no `#[default(..)]` for it, and \
470 no `create_column` names it, so every create would fail at the driver"
471 ),
472 }
473 }
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478#[non_exhaustive]
479pub enum SegmentFault {
480 Empty,
482 Dot,
484 Char(char),
486}
487
488impl fmt::Display for SegmentFault {
489 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
490 match self {
491 Self::Empty => f.write_str("is empty"),
492 Self::Dot => f.write_str("is '.' or '..'"),
493 Self::Char(c) => write!(
494 f,
495 "contains {c:?}: quotes, backslashes, control characters, whitespace, URL \
496 punctuation and the route pattern characters '{{', '}}', '(' and ')' are refused"
497 ),
498 }
499 }
500}
501
502pub(crate) const fn segment_fault(segment: &str) -> Option<SegmentFault> {
506 let bytes = segment.as_bytes();
507 if bytes.is_empty() {
508 return Some(SegmentFault::Empty);
509 }
510 if matches!(bytes, b"." | b"..") {
511 return Some(SegmentFault::Dot);
512 }
513 let mut at = 0;
514 while at < bytes.len() {
515 let (c, width) = decode_char(bytes, at);
516 if c.is_control()
517 || c.is_whitespace()
518 || matches!(
519 c,
520 '"' | '\\' | '/' | '?' | '#' | '%' | '&' | '=' | '{' | '}' | '(' | ')'
521 )
522 {
523 return Some(SegmentFault::Char(c));
524 }
525 at += width;
526 }
527 None
528}
529
530const fn decode_char(bytes: &[u8], at: usize) -> (char, usize) {
532 let lead = bytes[at] as u32;
533 let (code, width) = if lead < 0x80 {
534 (lead, 1)
535 } else if lead < 0xE0 {
536 ((lead & 0x1F) << 6 | tail(bytes, at + 1), 2)
537 } else if lead < 0xF0 {
538 (
539 (lead & 0x0F) << 12 | tail(bytes, at + 1) << 6 | tail(bytes, at + 2),
540 3,
541 )
542 } else {
543 (
544 (lead & 0x07) << 18
545 | tail(bytes, at + 1) << 12
546 | tail(bytes, at + 2) << 6
547 | tail(bytes, at + 3),
548 4,
549 )
550 };
551 match char::from_u32(code) {
552 Some(c) => (c, width),
553 None => panic!("a str holds valid UTF-8"),
554 }
555}
556
557const fn tail(bytes: &[u8], at: usize) -> u32 {
559 bytes[at] as u32 & 0x3F
560}
561
562#[cfg(test)]
563mod tests;