Skip to main content

tablo_core/
form.rs

1//! Record forms: the typed value a form submission parses into, and the write
2//! that stores it.
3//!
4//! Declares one `RecordForm` struct with one field per written model column.
5//! The struct is the form's one source of truth: it parses every key, and a
6//! field's blank answer decides whether its control is required. Completes
7//! unposted keys from the stored record and writes only named fields plus model
8//! defaults.
9//!
10//! ```no_run
11//! #[derive(Debug, Clone, toasty::Model)]
12//! struct User {
13//!     #[key]
14//!     #[auto]
15//!     id: uuid::Uuid,
16//!     name: String,
17//!     age: i64,
18//! }
19//!
20//! #[derive(tablo_core::RecordForm)]
21//! #[form(model = User)]
22//! struct UserForm {
23//!     name: String,
24//!     #[form(blank = 0)]
25//!     age: i64,
26//! }
27//!
28//! // A column type toasty stores but the form edge cannot spell.
29//! #[derive(Debug, Clone, toasty::Model)]
30//! struct Tagged {
31//!     #[key]
32//!     #[auto]
33//!     id: uuid::Uuid,
34//!     tags: Vec<String>,
35//! }
36//! ```
37//!
38//! # Compile-time refusals
39//!
40//! A field the model does not have:
41//!
42//! ```compile_fail
43//! # #[derive(Debug, Clone, toasty::Model)]
44//! # struct User { #[key] #[auto] id: uuid::Uuid, name: String, age: i64 }
45//! #[derive(tablo_core::RecordForm)]
46//! #[form(model = User)]
47//! struct UserForm {
48//!     nickname: String,
49//! }
50//! ```
51//!
52//! A type the model's field does not have:
53//!
54//! ```compile_fail
55//! # #[derive(Debug, Clone, toasty::Model)]
56//! # struct User { #[key] #[auto] id: uuid::Uuid, name: String, age: i64 }
57//! #[derive(tablo_core::RecordForm)]
58//! #[form(model = User)]
59//! struct UserForm {
60//!     age: i32,
61//! }
62//! ```
63//!
64//! A bare choice, which would offer nothing: a foreign key names its source with
65//! `#[form(relationship = ..)]`.
66//!
67//! ```compile_fail
68//! # #[derive(Debug, Clone, toasty::Model)]
69//! # struct Post { #[key] #[auto] id: uuid::Uuid, author_id: uuid::Uuid }
70//! #[derive(tablo_core::RecordForm)]
71//! #[form(model = Post)]
72//! struct PostForm {
73//!     #[form(choice)]
74//!     author_id: uuid::Uuid,
75//! }
76//! ```
77//!
78//! Two keys that each pick the control:
79//!
80//! ```compile_fail
81//! # #[derive(Debug, Clone, toasty::Model)]
82//! # struct Post { #[key] #[auto] id: uuid::Uuid, author_id: String }
83//! # struct Authors;
84//! #[derive(tablo_core::RecordForm)]
85//! #[form(model = Post)]
86//! struct PostForm {
87//!     #[form(relationship = Authors, file)]
88//!     author_id: String,
89//! }
90//! ```
91//!
92//! A scalar type the form edge cannot spell:
93//!
94//! ```compile_fail
95//! #[derive(Debug, Clone, toasty::Model)]
96//! struct Tagged {
97//!     #[key]
98//!     #[auto]
99//!     id: uuid::Uuid,
100//!     tags: Vec<String>,
101//! }
102//!
103//! #[derive(tablo_core::RecordForm)]
104//! #[form(model = Tagged)]
105//! struct TaggedForm {
106//!     tags: Vec<String>,
107//! }
108//! ```
109//!
110//! A many-to-many field whose keys are not its source's primary key:
111//!
112//! ```compile_fail
113//! # #[derive(Debug, Clone, toasty::Model)]
114//! # struct Post {
115//! #     #[key] #[auto] id: uuid::Uuid,
116//! #     #[has_many] taggings: toasty::Deferred<Vec<Tagging>>,
117//! #     #[has_many(via = taggings.tag)] tags: toasty::Deferred<Vec<Tag>>,
118//! # }
119//! # #[derive(Debug, Clone, toasty::Model)]
120//! # struct Tag { #[key] #[auto] id: uuid::Uuid }
121//! # #[derive(Debug, Clone, toasty::Model)]
122//! # struct Tagging {
123//! #     #[key] #[auto] id: uuid::Uuid,
124//! #     #[index] post_id: uuid::Uuid,
125//! #     #[belongs_to(key = post_id, references = id)] post: toasty::Deferred<Post>,
126//! #     #[index] tag_id: uuid::Uuid,
127//! #     #[belongs_to(key = tag_id, references = id)] tag: toasty::Deferred<Tag>,
128//! # }
129//! # struct TagResource;
130//! # impl tablo_core::Resource for TagResource { type Model = Tag; type Form = tablo_core::NoForm<Tag>; }
131//! #[derive(tablo_core::RecordForm)]
132//! #[form(model = Post)]
133//! struct PostForm {
134//!     #[form(relationship = TagResource)]
135//!     tags: Vec<i64>,
136//! }
137//! ```
138//!
139//! `optional` on a type with no empty value:
140//!
141//! ```compile_fail
142//! # #[derive(Debug, Clone, toasty::Model)]
143//! # struct User { #[key] #[auto] id: uuid::Uuid, name: String, age: i64 }
144//! #[derive(tablo_core::RecordForm)]
145//! #[form(model = User)]
146//! struct UserForm {
147//!     #[form(optional)]
148//!     age: i64,
149//! }
150//! ```
151//!
152//! `embed` on a type that is not an [`EmbeddedForm`](crate::EmbeddedForm):
153//!
154//! ```compile_fail
155//! # #[derive(Debug, Clone, toasty::Model)]
156//! # struct User { #[key] #[auto] id: uuid::Uuid, name: String, age: i64 }
157//! #[derive(tablo_core::RecordForm)]
158//! #[form(model = User)]
159//! struct UserForm {
160//!     #[form(embed)]
161//!     name: String,
162//! }
163//! ```
164
165use std::{
166    collections::{HashMap, HashSet},
167    fmt::Debug,
168    hash::Hash,
169};
170
171use toasty::{Executor, schema::Model};
172use topcoat::context::Cx;
173
174use crate::{
175    detail::Detail,
176    schema::{FieldResolver, OptionSource, Schema, TypedValue},
177    table::{Table, column::Includes},
178};
179
180/// A type one form key reads and writes: `String`, every [`TypedValue`] type,
181/// every enum deriving [`Options`](crate::Options), and an `Option` of any of them.
182///
183/// The value the parse sees is trimmed and non-empty; an empty submission is the
184/// record-form field's **blank answer** instead, and a field with none is required.
185///
186/// A text field binds a path of any `FormScalar` type
187/// ([`Field::text`](crate::Field::text)), and the form derives read and write
188/// every field that is not `#[form(embed)]` through it.
189#[diagnostic::on_unimplemented(
190    message = "`{Self}` is not a form scalar",
191    label = "a form field of this type has no text spelling",
192    note = "a form scalar is `String`, a `TypedValue` type, an `Options` enum, or an `Option` of \
193            one; implement `TypedValue` for an app type, derive `Options` for a unit enum, or \
194            mark an `EmbeddedForm` value `#[form(embed)]`"
195)]
196pub trait FormScalar: Sized {
197    /// The `type` attribute of the text control that edits it.
198    const INPUT_TYPE: &'static str = "text";
199
200    /// Parse a trimmed, non-empty submission, or return the error message.
201    fn parse_form(value: &str) -> std::result::Result<Self, String>;
202
203    /// The form spelling of a stored value.
204    fn to_form(&self) -> String;
205
206    /// What a column cell and a group header read: the form spelling, or an
207    /// `Options` enum's label.
208    fn to_label(&self) -> String {
209        self.to_form()
210    }
211}
212
213impl FormScalar for String {
214    fn parse_form(value: &str) -> std::result::Result<Self, String> {
215        Ok(value.to_string())
216    }
217
218    fn to_form(&self) -> String {
219        self.clone()
220    }
221}
222
223impl<T: TypedValue> FormScalar for T {
224    const INPUT_TYPE: &'static str = T::INPUT_TYPE;
225
226    fn parse_form(value: &str) -> std::result::Result<Self, String> {
227        T::parse_input(value).ok_or_else(|| format!("`{value}` is not a valid {}", T::NOUN))
228    }
229
230    fn to_form(&self) -> String {
231        self.to_string()
232    }
233}
234
235/// A form scalar whose `Option` is one too: every [`TypedValue`] type, and every enum deriving
236/// [`Options`](crate::Options), which implements it.
237#[doc(hidden)]
238pub trait NullableScalar: FormScalar {}
239
240impl<T: TypedValue> NullableScalar for T {}
241
242impl<T: NullableScalar> FormScalar for Option<T> {
243    const INPUT_TYPE: &'static str = T::INPUT_TYPE;
244
245    fn parse_form(value: &str) -> std::result::Result<Self, String> {
246        T::parse_form(value).map(Some)
247    }
248
249    fn to_form(&self) -> String {
250        self.as_ref().map(T::to_form).unwrap_or_default()
251    }
252
253    fn to_label(&self) -> String {
254        self.as_ref().map(T::to_label).unwrap_or_default()
255    }
256}
257
258impl FormScalar for Option<String> {
259    fn parse_form(value: &str) -> std::result::Result<Self, String> {
260        Ok(Some(value.to_string()))
261    }
262
263    fn to_form(&self) -> String {
264        self.clone().unwrap_or_default()
265    }
266}
267
268/// Compiles only for a form scalar: the form derives call it, spanned on a
269/// field's type, so a field of another type fails there.
270#[doc(hidden)]
271pub fn assert_form_scalar<T: FormScalar>() {}
272
273/// Why a key was refused.
274#[derive(Debug, Clone, PartialEq, Eq)]
275#[non_exhaustive]
276pub enum FieldErrorKind {
277    /// Posted empty, and the field has no blank answer. The form renders
278    /// "{label} is required" under the control.
279    Required,
280    /// Posted a value the field refuses, with the sentence the form renders.
281    Invalid(String),
282}
283
284/// One key a submission was refused under: a form key, or a record form's
285/// field enum in [`Resource::validate_record`](crate::Resource::validate_record).
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct FieldError<K = String> {
288    /// What the error renders under.
289    pub key: K,
290    /// Why the key was refused.
291    pub kind: FieldErrorKind,
292}
293
294impl<K> FieldError<K> {
295    /// `key` was posted empty and has no blank answer.
296    pub fn required(key: impl Into<K>) -> Self {
297        Self {
298            key: key.into(),
299            kind: FieldErrorKind::Required,
300        }
301    }
302
303    /// `key` carried a value it refuses; `message` says why.
304    pub fn invalid(key: impl Into<K>, message: impl Into<String>) -> Self {
305        Self {
306            key: key.into(),
307            kind: FieldErrorKind::Invalid(message.into()),
308        }
309    }
310
311    /// The sentence the form renders under the control labelled `label`.
312    pub fn message(&self, label: &str) -> String {
313        match &self.kind {
314            FieldErrorKind::Required => format!("{label} is required"),
315            FieldErrorKind::Invalid(message) => message.clone(),
316        }
317    }
318}
319
320/// Read one scalar from a completed submission.
321///
322/// Trims the value; an empty one is `blank`, the field's blank answer, or
323/// refuses as [`FieldErrorKind::Required`] when it has none.
324pub fn parse_scalar<T: FormScalar>(
325    key: &str,
326    values: &HashMap<String, String>,
327    blank: Option<T>,
328) -> std::result::Result<T, FieldError> {
329    let raw = values.get(key).map(|value| value.trim()).unwrap_or("");
330    if raw.is_empty() {
331        return blank.ok_or_else(|| FieldError::required(key));
332    }
333    T::parse_form(raw).map_err(|message| FieldError::invalid(key, message))
334}
335
336/// Read a password from a completed submission: required as [`parse_scalar`] reads a `String`,
337/// but kept as typed, with its outer spaces, since a login compares the password untrimmed.
338pub fn parse_password(
339    key: &str,
340    values: &HashMap<String, String>,
341    blank: Option<String>,
342) -> std::result::Result<String, FieldError> {
343    parse_scalar::<String>(key, values, blank)?;
344    Ok(values.get(key).cloned().unwrap_or_default())
345}
346
347/// The values a multiple choice holds, as its one form key carries them: what a record form
348/// hydrates a many-to-many field with, and what a hand-written one writes.
349pub fn write_list<'a, T: FormScalar + 'a>(values: impl IntoIterator<Item = &'a T>) -> String {
350    encode_list(&values.into_iter().map(T::to_form).collect::<Vec<_>>())
351}
352
353/// Read the values a multiple choice posted, none when it posted nothing: what a record form's
354/// parse reads a many-to-many field with.
355///
356/// The panel folds the values the choice posts under its key into the key's one value before any
357/// parse reads it.
358///
359/// # Errors
360///
361/// A value that is not a list, or one `T` refuses, under `key`.
362pub fn parse_list<T: FormScalar>(
363    key: &str,
364    values: &HashMap<String, String>,
365) -> std::result::Result<Vec<T>, FieldError> {
366    let Some(items) = decode_list(values.get(key).map_or("", String::as_str)) else {
367        return Err(FieldError::invalid(key, "The choices could not be read"));
368    };
369    items
370        .iter()
371        .map(|item| T::parse_form(item).map_err(|message| FieldError::invalid(key, message)))
372        .collect()
373}
374
375/// `items` as one form value.
376pub(crate) fn encode_list(items: &[String]) -> String {
377    serde_json::to_string(items).expect("a list of strings serializes")
378}
379
380/// The items one form value holds: none for a blank value, `None` for one that is not a list.
381pub(crate) fn decode_list(value: &str) -> Option<Vec<String>> {
382    if value.trim().is_empty() {
383        return Some(Vec::new());
384    }
385    serde_json::from_str(value).ok()
386}
387
388/// The form key of the many-to-many field `path`, a `#[has_many(via = ..)]` list of `R`'s
389/// records, holding their keys of type `K`: the derive calls it, so a field whose type is not
390/// `Vec<K>` of the related model's key, or whose source is not that model's, fails to compile.
391#[doc(hidden)]
392pub fn links_key<M, R, K>(
393    path: impl Into<toasty::stmt::Path<M, toasty::stmt::List<R::Model>>>,
394) -> String
395where
396    M: Model,
397    R: OptionSource,
398    R::Model: Model<PrimaryKey = K>,
399    K: FormScalar,
400{
401    crate::schema::form_key(path.into())
402}
403
404/// `includes` and the include loading the many-to-many field `path`, which
405/// [`RecordForm::hydrate`] reads.
406#[doc(hidden)]
407pub fn links_include<M, R>(
408    includes: Includes<M>,
409    path: impl Into<toasty::stmt::Path<M, toasty::stmt::List<R::Model>>>,
410) -> Includes<M>
411where
412    M: Model,
413    R: OptionSource,
414{
415    includes.with(path.into())
416}
417
418/// The keys of the records a many-to-many field holds, as its form key carries them, or `None`
419/// when the record was loaded without them.
420#[doc(hidden)]
421pub fn write_links<T>(records: &toasty::Deferred<Vec<T>>) -> Option<String>
422where
423    T: Model + toasty::stmt::IntoExpr<T>,
424{
425    (!records.is_unloaded()).then(|| {
426        encode_list(
427            &records
428                .get()
429                .iter()
430                .map(crate::toasty_compat::pk::pk_text)
431                .collect::<Vec<_>>(),
432        )
433    })
434}
435
436/// One record-form field and the form keys it binds.
437#[derive(Debug, Clone)]
438pub struct FormField<K> {
439    /// The field, as the form's field enum names it.
440    pub field: K,
441    /// The Rust field name, for build-time refusals.
442    pub name: &'static str,
443    /// The keys the field binds: one for a scalar, every key of an embedded
444    /// value (an enum's discriminant first). An error on the field renders
445    /// under the first.
446    pub keys: Vec<String>,
447    /// The keys with no blank answer, which an empty submission refuses: the
448    /// panel renders their controls required.
449    pub required: Vec<String>,
450}
451
452/// The typed value a resource's form submission parses into.
453///
454/// Derive it with [`tablo_core::RecordForm`](crate::RecordForm); a
455/// hand-written impl is what the derive expands to.
456pub trait RecordForm: Sized + Send + 'static {
457    /// The model the form writes.
458    type Model: Model + toasty::stmt::IntoExpr<Self::Model> + Send + Sync + 'static;
459
460    /// One variant per form field: the key [`Posted`] uses. Its bound form keys
461    /// are [`Self::fields`]'.
462    type Field: Copy + Eq + Hash + Debug + Send + Sync + 'static;
463
464    /// Every field, in declaration order, with the keys it binds through `resolver`'s app schema.
465    fn fields(resolver: &FieldResolver) -> Vec<FormField<Self::Field>>;
466
467    /// The control the form renders `field` with when its [`form`](crate::ResourceDef::form)
468    /// does not place it: what `controls()` hands over for the field, as a one-control schema.
469    ///
470    /// A resource's form renders every field it does not place after the ones it does, in
471    /// declaration order, so a resource that declares no form renders one control per field.
472    fn control(field: Self::Field) -> Schema<Self>;
473
474    /// The form's default table: one column per field a column can show, in declaration order.
475    /// A [`ResourceDef`](crate::ResourceDef) without a [`table`](crate::ResourceDef::table) lists
476    /// it.
477    ///
478    /// The derive lists each text field in a sortable column, searchable over a `String` or
479    /// `Option<String>`, an options field by its option's label, and a toggle as yes or no (see
480    /// [`RecordForm`](derive@crate::RecordForm)). The default here lists none, so a resource
481    /// whose form lists nothing, such as one naming [`NoForm`], declares its own.
482    fn table() -> Table<Self::Model> {
483        Table::new(())
484    }
485
486    /// The form's default detail page: one column per field a column can show, in declaration
487    /// order. A [`ResourceDef`](crate::ResourceDef) without a [`view`](crate::ResourceDef::view)
488    /// shows it.
489    ///
490    /// The derive shows each field [`table`](Self::table) lists, a relationship as the key it
491    /// holds, a file field as a link, and an embedded value leaf by leaf (see
492    /// [`RecordForm`](derive@crate::RecordForm)). A relation's record shows through a
493    /// [`RelationColumn`](crate::RelationColumn), in a declared
494    /// [`view`](crate::ResourceDef::view). The default here shows none, which turns the detail page
495    /// off for a resource naming [`NoForm`].
496    fn detail() -> Detail<Self::Model> {
497        Detail::empty()
498    }
499
500    /// The relations [`hydrate`](Self::hydrate) reads off the record: each many-to-many field's,
501    /// which the edit page and the edit's write load the record with.
502    fn includes() -> Includes<Self::Model> {
503        Includes::new()
504    }
505
506    /// The stored record as the form spells it.
507    fn hydrate(cx: &Cx, record: &Self::Model) -> HashMap<String, String>;
508
509    /// The records each many-to-many field holds, as their keys' form spelling: what
510    /// [`write_create`](crate::resource::write_create) links a new record to, and what
511    /// [`write_update`](crate::resource::write_update) leaves a posted field's record linked to.
512    fn links(&self) -> Vec<(Self::Field, Vec<String>)> {
513        Vec::new()
514    }
515
516    /// Parse a completed submission: the form's only presence and type check.
517    ///
518    /// # Errors
519    ///
520    /// Every key that failed, each once, under one of [`Self::fields`]' keys.
521    fn parse(
522        cx: &Cx,
523        values: &HashMap<String, String>,
524    ) -> std::result::Result<Self, Vec<FieldError>>;
525
526    /// The create builder with every field set.
527    fn into_create(self) -> <Self::Model as Model>::Create;
528
529    /// The instance update builder with one assignment per named field, or
530    /// `None` when no field is named.
531    ///
532    /// `None` is what keeps a submission that names no field from building an
533    /// empty update, which toasty refuses with an assertion.
534    fn into_update<'a>(
535        self,
536        record: &'a mut Self::Model,
537        named: &HashSet<Self::Field>,
538    ) -> Option<<Self::Model as Model>::Update<'a>>;
539
540    /// Execute a builder [`Self::into_update`] returned.
541    ///
542    /// Toasty's instance update builder implements no trait that carries
543    /// `exec`, so the generic write reaches it through the form.
544    fn exec_update<'a>(
545        update: <Self::Model as Model>::Update<'a>,
546        ex: &'a mut dyn Executor,
547    ) -> impl Future<Output = toasty::Result<()>> + Send + 'a;
548
549    /// Whether the resource naming this form has create and edit pages.
550    ///
551    /// [`Panel::resource`](crate::Panel::resource) registers the create, edit,
552    /// and options routes only when this holds. [`NoForm`] sets it to `false`.
553    const HAS_FORM: bool = true;
554}
555
556/// The record form of a resource with no create or edit page.
557///
558/// A list-only resource names it as [`Resource::Form`](crate::Resource::Form):
559/// `type Form = NoForm<Self::Model>;`. [`fields`](RecordForm::fields) and
560/// [`hydrate`](RecordForm::hydrate) are empty and
561/// [`into_update`](RecordForm::into_update) answers `None`. No route parses or
562/// writes it, so [`parse`](RecordForm::parse) and
563/// [`into_create`](RecordForm::into_create) panic naming the model, and the
564/// future [`exec_update`](RecordForm::exec_update) returns panics when polled.
565pub struct NoForm<M>(std::marker::PhantomData<fn() -> M>);
566
567impl<M: Model + toasty::stmt::IntoExpr<M> + Send + Sync + 'static> NoForm<M> {
568    fn unreachable() -> ! {
569        panic!("`NoForm<{}>` has no form", std::any::type_name::<M>())
570    }
571}
572
573impl<M: Model + toasty::stmt::IntoExpr<M> + Send + Sync + 'static> RecordForm for NoForm<M> {
574    type Model = M;
575    type Field = std::convert::Infallible;
576
577    const HAS_FORM: bool = false;
578
579    fn fields(_resolver: &FieldResolver) -> Vec<FormField<Self::Field>> {
580        Vec::new()
581    }
582
583    fn control(field: Self::Field) -> Schema<Self> {
584        match field {}
585    }
586
587    fn hydrate(_cx: &Cx, _record: &M) -> HashMap<String, String> {
588        HashMap::new()
589    }
590
591    fn parse(
592        _cx: &Cx,
593        _values: &HashMap<String, String>,
594    ) -> std::result::Result<Self, Vec<FieldError>> {
595        Self::unreachable()
596    }
597
598    fn into_create(self) -> M::Create {
599        Self::unreachable()
600    }
601
602    fn into_update<'a>(
603        self,
604        _record: &'a mut M,
605        _named: &HashSet<Self::Field>,
606    ) -> Option<M::Update<'a>> {
607        None
608    }
609
610    fn exec_update<'a>(
611        update: M::Update<'a>,
612        ex: &'a mut dyn Executor,
613    ) -> impl Future<Output = toasty::Result<()>> + Send + 'a {
614        // `into_update` answers `None`, so no builder reaches here. The
615        // builder is not known to be `Send`, so the future must not hold it.
616        drop((update, ex));
617        async { Self::unreachable() }
618    }
619}
620
621/// An edit submission: the parsed form, and the fields the submission named.
622///
623/// Derefs to the form, so a record fn reads `posted.author_id` — the posted
624/// value, or the stored one when the submission did not name the field.
625pub struct Posted<F: RecordForm> {
626    form: F,
627    named: HashSet<F::Field>,
628}
629
630impl<F: RecordForm> Posted<F> {
631    /// A posted form that names `named`.
632    pub fn new(form: F, named: impl IntoIterator<Item = F::Field>) -> Self {
633        Self {
634            form,
635            named: named.into_iter().collect(),
636        }
637    }
638
639    /// Whether the submission named `field`.
640    pub fn named(&self, field: F::Field) -> bool {
641        self.named.contains(&field)
642    }
643
644    /// The update builder assigning every named field, or `None` when the
645    /// submission named none. See [`RecordForm::into_update`].
646    pub fn into_update(self, record: &mut F::Model) -> Option<<F::Model as Model>::Update<'_>> {
647        self.form.into_update(record, &self.named)
648    }
649}
650
651impl<F: RecordForm> std::ops::Deref for Posted<F> {
652    type Target = F;
653
654    fn deref(&self) -> &F {
655        &self.form
656    }
657}
658
659/// A refused submission.
660///
661/// [`Resource::validate_record`](crate::Resource::validate_record) keys it by the record form's
662/// field enum (`FieldErrors<UserFormField>`), so an error always names a field the form renders;
663/// one on an embedded value renders under its first control. The panel keys every error by the
664/// form key it renders under (`FieldErrors`, keyed by `String`), and [`Source::form`] reads them
665/// back when a schema renders.
666///
667/// [`Source::form`]: crate::Source::form
668#[derive(Debug)]
669pub struct FieldErrors<K = String> {
670    errors: Vec<FieldError<K>>,
671}
672
673impl<K> Default for FieldErrors<K> {
674    fn default() -> Self {
675        Self { errors: Vec::new() }
676    }
677}
678
679impl<K> FieldErrors<K> {
680    /// No errors.
681    pub fn new() -> Self {
682        Self::default()
683    }
684
685    /// Refuse `key` with `message`.
686    pub fn add(&mut self, key: impl Into<K>, message: impl Into<String>) {
687        self.errors.push(FieldError::invalid(key, message));
688    }
689
690    /// Refuse `error`'s key with `error`.
691    pub fn push(&mut self, error: FieldError<K>) {
692        self.errors.push(error);
693    }
694
695    /// Whether nothing was refused.
696    pub fn is_empty(&self) -> bool {
697        self.errors.is_empty()
698    }
699
700    /// Every error, in the order it was added.
701    pub fn iter(&self) -> impl Iterator<Item = &FieldError<K>> {
702        self.errors.iter()
703    }
704
705    /// Append `other`'s errors after these.
706    pub fn extend(&mut self, other: Self) {
707        self.errors.extend(other.errors);
708    }
709}
710
711impl<K> IntoIterator for FieldErrors<K> {
712    type Item = FieldError<K>;
713    type IntoIter = std::vec::IntoIter<FieldError<K>>;
714
715    fn into_iter(self) -> Self::IntoIter {
716        self.errors.into_iter()
717    }
718}
719
720impl FieldErrors {
721    /// Whether any error renders under `key`.
722    pub fn contains_key(&self, key: &str) -> bool {
723        self.errors.iter().any(|error| error.key == key)
724    }
725
726    /// The error `key` renders: the first one added.
727    pub fn first(&self, key: &str) -> Option<&FieldError> {
728        self.errors.iter().find(|error| error.key == key)
729    }
730
731    /// Take `other`'s errors, dropping this collection's own errors under every
732    /// key `other` names.
733    ///
734    /// A source that owns a key answers for it: a rejected upload replaces the
735    /// error the emptied control would otherwise report.
736    pub(crate) fn replace(&mut self, other: Self) {
737        let owned: HashSet<&str> = other
738            .errors
739            .iter()
740            .map(|error| error.key.as_str())
741            .collect();
742        self.errors
743            .retain(|kept| !owned.contains(kept.key.as_str()));
744        self.errors.extend(other.errors);
745    }
746}
747
748#[cfg(test)]
749mod tests;