Skip to main content

ctl_core/
input.rs

1//! Declared input: read a file, parse it, validate it once, and report every
2//! problem on its file and line.
3//!
4//! Each format is its own feature. `input-yaml` parses YAML with
5//! `serde-saphyr`, and `input-toml` parses TOML with the `toml` crate; neither
6//! needs the other. `validate` adds `Input::check` and `Input::load`, which
7//! validate with garde, and `input` is `input-yaml` with `validate`. This is
8//! the one boundary where a CLI's config enters. Downstream code keeps the
9//! validated type and never checks it again.
10//!
11//! A check that runs after the read, such as one that needs a file the config
12//! names, places its problem with a [`FieldPath`] and [`Input::problem_at`],
13//! the way the loader places garde's.
14//!
15//! A text's [`Format`] is chosen, never guessed. [`Input::open`] reads it from
16//! the file's extension, and [`Input::new_with_format`] takes it for text in
17//! memory. With `input-yaml`, `Input::new` and `Input::read` are YAML.
18//!
19//! ```
20//! # #[cfg(feature = "input")]
21//! # {
22//! use ctl_core::input::Input;
23//! use serde::Deserialize;
24//!
25//! #[derive(Debug, Deserialize)]
26//! struct Config {
27//!     name: String,
28//! }
29//!
30//! let config: Config = Input::new("toy.yaml", "name: release\n").parse()?;
31//! assert_eq!(config.name, "release");
32//!
33//! let error = Input::new("toy.yaml", "name: a\nname: b\n")
34//!     .parse::<Config>()
35//!     .unwrap_err();
36//! assert!(error.to_string().starts_with("toy.yaml"));
37//! # }
38//! # Ok::<(), ctl_core::input::InputError>(())
39//! ```
40
41use std::path::Path;
42use std::{fmt, io};
43
44#[cfg(feature = "validate")]
45use garde::Validate;
46use serde::de::DeserializeOwned;
47
48mod entries;
49mod field_path;
50mod limits;
51#[cfg(feature = "input-toml")]
52mod toml_entries;
53#[cfg(feature = "input-toml")]
54mod toml_text;
55#[cfg(feature = "input-yaml")]
56mod yaml_entries;
57#[cfg(feature = "input-yaml")]
58mod yaml_text;
59
60pub use entries::{Entry, Written};
61pub use field_path::{FieldPath, Place, Step};
62use limits::{Exceeded, Raise};
63pub use limits::{Limits, set_default_limits};
64#[cfg(feature = "input-yaml")]
65pub use limits::{Loosened, REPO_FILE, use_repo_limits};
66/// A value and the YAML tag written on its node, such as `!override` in
67/// `code_span_size: !override 0`: `Tagged(0, Some("!override".into()))`.
68/// A node with no tag reads as `Tagged(value, None)`, and an untagged enum
69/// inside reads the plain value of a tagged node.
70#[cfg(feature = "input-yaml")]
71pub use serde_saphyr::Tagged;
72
73/// What a placing answers with for a key of the path: where its value starts,
74/// or where the key itself is written.
75#[derive(Clone, Copy)]
76enum At {
77    Value,
78    Key,
79}
80
81/// How a text is written.
82///
83/// Non-exhaustive: each variant exists only with its format's feature.
84/// Cargo unifies features across a build, so a caller's `match` needs a
85/// wildcard arm.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum Format {
89    /// YAML, read by serde-saphyr and placed by yamled.
90    #[cfg(feature = "input-yaml")]
91    Yaml,
92    /// TOML, read and placed by the `toml` crate.
93    #[cfg(feature = "input-toml")]
94    Toml,
95}
96
97impl Format {
98    /// The format a file's extension names, compared without regard to case:
99    /// `.toml` is TOML, and `.yaml` and `.yml` are YAML.
100    ///
101    /// # Errors
102    ///
103    /// An [`InputError`] with no line for any other extension or none, naming
104    /// the extension and the accepted ones. One naming the feature for an
105    /// extension whose format's feature is off.
106    pub fn of(path: &Path) -> Result<Self, InputError> {
107        let name = path.display().to_string();
108        let extension = path
109            .extension()
110            .map(|extension| extension.to_string_lossy().to_ascii_lowercase());
111        match extension.as_deref() {
112            #[cfg(feature = "input-yaml")]
113            Some("yaml" | "yml") => Ok(Self::Yaml),
114            #[cfg(not(feature = "input-yaml"))]
115            Some(yaml @ ("yaml" | "yml")) => Err(InputError::whole(
116                name,
117                format!("`.{yaml}` needs ctl-core's input-yaml feature; {ACCEPTED}"),
118            )),
119            #[cfg(feature = "input-toml")]
120            Some("toml") => Ok(Self::Toml),
121            #[cfg(not(feature = "input-toml"))]
122            Some("toml") => Err(InputError::whole(
123                name,
124                format!("`.toml` needs ctl-core's input-toml feature; {ACCEPTED}"),
125            )),
126            Some(other) => Err(InputError::whole(
127                name,
128                format!("unsupported extension .{other}; {ACCEPTED}"),
129            )),
130            None => Err(InputError::whole(
131                name,
132                format!("no file extension; {ACCEPTED}"),
133            )),
134        }
135    }
136}
137
138/// What a refusal tells a person to name the file: the extensions this build
139/// reads.
140#[cfg(all(feature = "input-yaml", feature = "input-toml"))]
141const ACCEPTED: &str = "name the file *.toml, *.yaml, or *.yml";
142#[cfg(all(feature = "input-yaml", not(feature = "input-toml")))]
143const ACCEPTED: &str = "name the file *.yaml or *.yml";
144#[cfg(all(feature = "input-toml", not(feature = "input-yaml")))]
145const ACCEPTED: &str = "name the file *.toml";
146
147/// A named text, ready to parse and validate.
148///
149/// The name is usually the file's path, and appears in every problem. Build
150/// one with [`Input::new_with_format`] for text in memory, or [`Input::open`]
151/// for a file. With `input-yaml`, `Input::new` and `Input::read` build a YAML
152/// one.
153#[derive(Clone, Debug, PartialEq, Eq)]
154pub struct Input {
155    name: String,
156    text: String,
157    lines_before: usize,
158    limits: Limits,
159    format: Format,
160    /// The schema fields serde flattens into their parents, which
161    /// [`Input::flattened`] declares.
162    #[cfg(feature = "validate")]
163    flattened: Vec<FieldPath>,
164    /// The YAML document that placing reads, built on first use.
165    #[cfg(feature = "input-yaml")]
166    yaml_document: yaml_text::Located,
167}
168
169/// Why an input was refused: every problem found, each placed on a line when
170/// the text allows it.
171///
172/// `Display` prints one problem per line as `name:line:column: field: message`,
173/// leaving out what is not known.
174///
175/// A file that could not be read keeps its [`io::ErrorKind`], which
176/// [`Self::io_failure`] returns. So a caller can treat a missing file apart
177/// from a refused one. A field can join this type in a minor release.
178#[derive(Clone, Debug, PartialEq, Eq)]
179#[non_exhaustive]
180pub struct InputError {
181    /// The input's name, usually its path.
182    pub name: String,
183    /// What is wrong, in the order found.
184    pub problems: Vec<Problem>,
185    /// The I/O failure, for an input that could not be read.
186    io_failure: Option<io::ErrorKind>,
187}
188
189/// One thing wrong with an input.
190#[derive(Clone, Debug, PartialEq, Eq)]
191pub struct Problem {
192    /// 1-based line, when known.
193    pub line: Option<usize>,
194    /// 1-based column, when known.
195    pub column: Option<usize>,
196    /// The field it concerns, as garde names it (`packages[1].name`); empty
197    /// for a problem with the whole text.
198    pub field: String,
199    /// What a person changes to fix it.
200    pub message: String,
201}
202
203impl Input {
204    /// A YAML input from text already in memory, whatever the extension of
205    /// `name`. Set another format with [`Input::format`].
206    #[cfg(feature = "input-yaml")]
207    #[must_use]
208    pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
209        Self::new_with_format(name, text, Format::Yaml)
210    }
211
212    /// An input of `format` from text already in memory, whatever the
213    /// extension of `name`. It needs only `format`'s feature, so a build
214    /// without YAML builds text in memory with it.
215    #[must_use]
216    pub fn new_with_format(
217        name: impl Into<String>,
218        text: impl Into<String>,
219        format: Format,
220    ) -> Self {
221        Self {
222            name: name.into(),
223            text: text.into(),
224            lines_before: 0,
225            limits: Limits::NONE,
226            format,
227            #[cfg(feature = "validate")]
228            flattened: Vec::new(),
229            #[cfg(feature = "input-yaml")]
230            yaml_document: yaml_text::Located::default(),
231        }
232    }
233
234    /// Read a YAML file, named by its path, whatever its extension.
235    ///
236    /// # Errors
237    ///
238    /// An [`InputError`] with the I/O failure.
239    #[cfg(feature = "input-yaml")]
240    pub fn read(path: &Path) -> Result<Self, InputError> {
241        let (name, text) = read_text(path)?;
242        Ok(Self::new(name, text))
243    }
244
245    /// Read a file and take its [`Format`] from its extension, as
246    /// [`Format::of`] does. The extension is checked before the file is read.
247    ///
248    /// # Errors
249    ///
250    /// An [`InputError`] for an extension [`Format::of`] refuses, or with the
251    /// I/O failure.
252    pub fn open(path: &Path) -> Result<Self, InputError> {
253        let format = Format::of(path)?;
254        let (name, text) = read_text(path)?;
255        Ok(Self::new_with_format(name, text, format))
256    }
257
258    /// Set how the text is written, over what the constructor took.
259    #[must_use]
260    pub fn format(mut self, format: Format) -> Self {
261        self.format = format;
262        self
263    }
264
265    /// The name given at construction, usually the file's path.
266    #[must_use]
267    pub fn name(&self) -> &str {
268        &self.name
269    }
270
271    /// The text as read, unchanged.
272    #[must_use]
273    pub fn text(&self) -> &str {
274        &self.text
275    }
276
277    /// Split YAML frontmatter from a Markdown body.
278    ///
279    /// The frontmatter is the text between a first line of `---` and the next
280    /// line of `---`. It is YAML whatever this input's format, and problems in
281    /// it keep the line numbers of the whole file. The second item is the body
282    /// after the closing line. Returns `None` when the text does not start with
283    /// a `---` line, or never closes it.
284    #[cfg(feature = "input-yaml")]
285    #[must_use]
286    pub fn frontmatter(&self) -> Option<(Self, &str)> {
287        let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
288        let rest = text
289            .strip_prefix("---\n")
290            .or_else(|| text.strip_prefix("---\r\n"))?;
291        let mut offset = 0;
292        for line in rest.split_inclusive('\n') {
293            if line.trim_end() == "---" {
294                let matter = Self {
295                    name: self.name.clone(),
296                    text: rest[..offset].to_owned(),
297                    lines_before: self.lines_before + 1,
298                    limits: self.limits.clone(),
299                    format: Format::Yaml,
300                    #[cfg(feature = "validate")]
301                    flattened: self.flattened.clone(),
302                    yaml_document: yaml_text::Located::default(),
303                };
304                return Some((matter, &rest[offset + line.len()..]));
305            }
306            offset += line.len();
307        }
308        None
309    }
310
311    /// Override limits for this input alone, above every other tier. See
312    /// [`Limits`] for the order.
313    #[must_use]
314    pub fn limits(mut self, limits: Limits) -> Self {
315        self.limits = limits;
316        self
317    }
318
319    /// Declare that the schema field at `field` is flattened into its parent
320    /// with `#[serde(flatten)]`, so the file writes its entries directly under
321    /// the parent.
322    ///
323    /// garde names the field in each problem's path all the same, and serde
324    /// gives no sign of the flattening at run time, so the schema declares it.
325    /// [`Input::check`] then drops the field's step from each garde path whose
326    /// leading steps are `field`, before it places the problem and prints its
327    /// field. `field` is written in the schema's terms, the way garde names it,
328    /// and each call adds one field. A declaration that matches no garde path
329    /// changes nothing. [`Input::place`], [`Input::place_key`], and
330    /// [`Input::problem_at`] take paths in the file's terms, so a declaration
331    /// does not touch them.
332    ///
333    /// # Examples
334    ///
335    /// verctl's `assets` holds its targets in a flattened map field `targets`.
336    /// Without the declaration, the problem prints as
337    /// `assets.targets.linux-x64.files` and sits on the line below `assets:`.
338    ///
339    /// ```
340    /// # #[cfg(feature = "input")]
341    /// # {
342    /// use std::collections::BTreeMap;
343    ///
344    /// use ctl_core::input::{FieldPath, Input};
345    /// use ctl_core::validate::at_least_one;
346    /// use garde::Validate;
347    /// use serde::Deserialize;
348    ///
349    /// #[derive(Debug, Deserialize, Validate)]
350    /// struct Config {
351    ///     #[garde(dive)]
352    ///     assets: Assets,
353    /// }
354    ///
355    /// #[derive(Debug, Deserialize, Validate)]
356    /// struct Assets {
357    ///     #[serde(flatten)]
358    ///     #[garde(dive)]
359    ///     targets: BTreeMap<String, Target>,
360    /// }
361    ///
362    /// #[derive(Debug, Deserialize, Validate)]
363    /// struct Target {
364    ///     #[garde(custom(at_least_one("file")))]
365    ///     files: Vec<String>,
366    /// }
367    ///
368    /// let error = Input::new("ver.yaml", "assets:\n  linux-x64:\n    files: []\n")
369    ///     .flattened(FieldPath::root().key("assets").key("targets"))
370    ///     .load::<Config>()
371    ///     .unwrap_err();
372    /// assert_eq!(
373    ///     error.to_string(),
374    ///     "ver.yaml:3:12: assets.linux-x64.files: must declare at least one file"
375    /// );
376    /// # }
377    /// ```
378    #[cfg(feature = "validate")]
379    #[must_use]
380    pub fn flattened(mut self, field: FieldPath) -> Self {
381        self.flattened.push(field);
382        self
383    }
384
385    /// `path` without the step of each declared flattened field it passes
386    /// through, or `None` when it passes through none.
387    #[cfg(feature = "validate")]
388    fn unflatten(&self, path: &FieldPath) -> Option<FieldPath> {
389        let positions: Vec<usize> = self
390            .flattened
391            .iter()
392            .filter(|field| path.starts_with(field))
393            .filter_map(|field| field.steps().len().checked_sub(1))
394            .collect();
395        (!positions.is_empty()).then(|| path.without(&positions))
396    }
397
398    /// Parse the text into `T`, without validating it.
399    ///
400    /// In YAML only `true` and `false` are booleans, and a duplicate key or a
401    /// merge key (`<<`) is refused. In TOML the format itself refuses a
402    /// duplicate key.
403    ///
404    /// The text must fit the limits in force, decided field by field through
405    /// the tiers [`Limits`] lists. TOML reads only `max_reader_input_bytes`,
406    /// `max_depth`, and `max_nodes`, because the other fields name YAML
407    /// syntax.
408    ///
409    /// # Errors
410    ///
411    /// An [`InputError`] with the parser's problem and its line. A limit
412    /// problem names the limit, its value, and where to raise it, or, without
413    /// `input-yaml`, that the tool sets it.
414    pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
415        self.parse_within(&limits::effective(&self.limits), Raise::FOR_TEXT)
416    }
417
418    /// [`Self::parse`] under exactly `limits`, whatever the tiers say.
419    fn parse_within<T: DeserializeOwned>(
420        &self,
421        limits: &Limits,
422        raise: Raise,
423    ) -> Result<T, InputError> {
424        self.fits(limits, raise)?;
425        match self.format {
426            #[cfg(feature = "input-yaml")]
427            Format::Yaml => yaml_text::parse(self, &limits.budget(), raise),
428            #[cfg(feature = "input-toml")]
429            Format::Toml => toml_text::parse(self, limits.bounds(), raise),
430        }
431    }
432
433    /// Whether the text is within the size `limits` allow.
434    fn fits(&self, limits: &Limits, raise: Raise) -> Result<(), InputError> {
435        let bounds = limits.bounds();
436        if self.text.len() > bounds.input_bytes {
437            return Err(InputError::whole(
438                self.name.clone(),
439                format!(
440                    "is {} bytes, which {}",
441                    self.text.len(),
442                    Exceeded::input_bytes(bounds.input_bytes, raise)
443                ),
444            ));
445        }
446        Ok(())
447    }
448
449    /// Validate a parsed `value` against `context`.
450    ///
451    /// Each of garde's problems lands on the line of the field it names, or
452    /// of its nearest written ancestor. The place comes from the parts of
453    /// garde's path through [`Input::place`], so a key that holds a `.` is
454    /// found as one key. `Problem::field` is garde's printed path, for display
455    /// only. A path through a field [`Input::flattened`] declares loses that
456    /// field's step, and its `Problem::field` is the shorter path as
457    /// [`FieldPath`] prints it.
458    ///
459    /// # Errors
460    ///
461    /// An [`InputError`] with every problem garde reports.
462    #[cfg(feature = "validate")]
463    pub fn check<T: Validate + ?Sized>(
464        &self,
465        value: &T,
466        context: &T::Context,
467    ) -> Result<(), InputError> {
468        let Err(report) = value.validate_with(context) else {
469            return Ok(());
470        };
471        let reported: Vec<_> = report
472            .iter()
473            .map(|(path, error)| {
474                let steps = FieldPath::from(path);
475                let (field, steps) = match self.unflatten(&steps) {
476                    Some(kept) => (kept.to_string(), kept),
477                    None => (path.to_string(), steps),
478                };
479                (field, steps, error.message().to_owned())
480            })
481            .collect();
482        let paths: Vec<_> = reported.iter().map(|(_, path, _)| path.clone()).collect();
483        let places = self.places(&paths, At::Value);
484        let problems = reported
485            .into_iter()
486            .zip(places)
487            .map(|((field, _, message), place)| Problem {
488                line: place.map(|at| at.line),
489                column: place.map(|at| at.column),
490                field,
491                message,
492            })
493            .collect();
494        Err(InputError::of(self.name.clone(), problems))
495    }
496
497    /// Where `path` is written in the text, or where its nearest written
498    /// ancestor is.
499    ///
500    /// The line counts the whole file, so a frontmatter input adds the lines
501    /// before it. A path the text lacks gives the place of the last step that
502    /// exists, and a caller that needs an exact hit compares the result with
503    /// the place of the path's parent. `None` when the text does not parse.
504    ///
505    /// `None` too when the text goes past the limits in force, as
506    /// [`Input::parse`] would refuse it, so placing never parses a text the
507    /// limits keep out. A YAML input checks `max_reader_input_bytes`,
508    /// `max_depth` and `max_nodes` before it hands the text to yamled, which
509    /// has no budget. A TOML input checks `max_reader_input_bytes`, and
510    /// the parse of its tree is the one [`Input::parse`] does first.
511    ///
512    /// A YAML input parses its text for the first placing and keeps the
513    /// result, so [`Input::check`] and later calls read one document. A TOML
514    /// input parses its text on each call.
515    ///
516    /// ```
517    /// # #[cfg(feature = "input-yaml")]
518    /// # {
519    /// use ctl_core::input::{FieldPath, Input};
520    ///
521    /// let input = Input::new("app.yaml", "files:\n  'docs/**/*.md':\n    label: ''\n");
522    /// let path = FieldPath::root()
523    ///     .key("files")
524    ///     .key("docs/**/*.md")
525    ///     .key("label");
526    /// let at = input.place(&path).map(|at| (at.line, at.column));
527    /// assert_eq!(at, Some((3, 12)));
528    /// # }
529    /// ```
530    #[must_use]
531    pub fn place(&self, path: &FieldPath) -> Option<Place> {
532        self.places(std::slice::from_ref(path), At::Value)
533            .pop()
534            .flatten()
535    }
536
537    /// Where the last step of `path` is written as a key, or where its nearest
538    /// written ancestor's key is.
539    ///
540    /// [`Input::place`] gives where a value starts. For a scalar or a flow
541    /// value that is the line of the key, and for a block mapping or a block
542    /// list it is the first line below it. A fault about the key itself, such
543    /// as a key that names nothing, belongs on the key, and this finds it.
544    ///
545    /// A step that is an index has no key, and neither has the root, so they
546    /// give the place of their value, as `place` does. A path the text lacks
547    /// gives the key of the last step that exists. The line counts the whole
548    /// file, `None` is for a text that does not parse or goes past the limits,
549    /// and a YAML document is built once and shared with [`Input::place`].
550    ///
551    /// ```
552    /// # #[cfg(feature = "input-yaml")]
553    /// # {
554    /// use ctl_core::input::{FieldPath, Input};
555    ///
556    /// let input = Input::new("app.yaml", "files:\n  'docs/**/*.md':\n    label: ''\n");
557    /// let glob = FieldPath::root().key("files").key("docs/**/*.md");
558    /// let value = input.place(&glob).map(|at| (at.line, at.column));
559    /// let key = input.place_key(&glob).map(|at| (at.line, at.column));
560    /// assert_eq!(value, Some((3, 5)));
561    /// assert_eq!(key, Some((2, 3)));
562    /// # }
563    /// ```
564    #[must_use]
565    pub fn place_key(&self, path: &FieldPath) -> Option<Place> {
566        self.places(std::slice::from_ref(path), At::Key)
567            .pop()
568            .flatten()
569    }
570
571    /// A [`Problem`] for `path` with `message`, placed as [`Input::place`]
572    /// places it, with the path printed as garde prints a field.
573    ///
574    /// A CLI uses it for a check that runs after the read, such as one that
575    /// needs a file the config names.
576    #[must_use]
577    pub fn problem_at(&self, path: &FieldPath, message: impl Into<String>) -> Problem {
578        let place = self.place(path);
579        Problem {
580            line: place.map(|at| at.line),
581            column: place.map(|at| at.column),
582            field: path.to_string(),
583            message: message.into(),
584        }
585    }
586
587    /// The place of each path, with the frontmatter lines added.
588    fn places(&self, paths: &[FieldPath], at: At) -> Vec<Option<Place>> {
589        let limits = limits::effective(&self.limits);
590        if self.text.len() > limits.bounds().input_bytes {
591            return vec![None; paths.len()];
592        }
593        let found = match self.format {
594            #[cfg(feature = "input-yaml")]
595            Format::Yaml => yaml_text::places(self, &limits, paths, at),
596            #[cfg(feature = "input-toml")]
597            Format::Toml => toml_text::places(&self.text, paths, at),
598        };
599        found
600            .into_iter()
601            .map(|at| at.map(|(line, column)| Place::new(line + self.lines_before, column)))
602            .collect()
603    }
604
605    /// Parse the text into `T`, then validate it with `T`'s default context.
606    ///
607    /// # Errors
608    ///
609    /// As [`Input::parse`] and [`Input::check`].
610    #[cfg(feature = "validate")]
611    pub fn load<T>(&self) -> Result<T, InputError>
612    where
613        T: DeserializeOwned + Validate,
614        T::Context: Default,
615    {
616        let value = self.parse::<T>()?;
617        self.check(&value, &T::Context::default())?;
618        Ok(value)
619    }
620}
621
622/// The file at `path`, named by its path, or the I/O failure.
623fn read_text(path: &Path) -> Result<(String, String), InputError> {
624    let name = path.display().to_string();
625    match std::fs::read_to_string(path) {
626        Ok(text) => Ok((name, text)),
627        Err(error) => Err(InputError::unread(name, &error)),
628    }
629}
630
631impl InputError {
632    /// A refusal of the whole input, not placed on a line.
633    fn whole(name: String, message: String) -> Self {
634        Self::of(
635            name,
636            vec![Problem {
637                line: None,
638                column: None,
639                field: String::new(),
640                message,
641            }],
642        )
643    }
644
645    /// The input `name` could not be read: the failure's text is the one
646    /// problem, and its `io::ErrorKind` is kept.
647    fn unread(name: String, error: &io::Error) -> Self {
648        Self {
649            io_failure: Some(error.kind()),
650            ..Self::whole(name, error.to_string())
651        }
652    }
653
654    /// The problems found in the input `name`, with no I/O failure.
655    const fn of(name: String, problems: Vec<Problem>) -> Self {
656        Self {
657            name,
658            problems,
659            io_failure: None,
660        }
661    }
662
663    /// The I/O failure when the file could not be read, such as
664    /// [`io::ErrorKind::NotFound`]. `None` when the text was read and refused.
665    #[must_use]
666    pub const fn io_failure(&self) -> Option<io::ErrorKind> {
667        self.io_failure
668    }
669}
670
671/// One problem per line: `name:line:column: field: message`, leaving out
672/// what is not known.
673impl fmt::Display for InputError {
674    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
675        for (index, problem) in self.problems.iter().enumerate() {
676            if index > 0 {
677                writeln!(formatter)?;
678            }
679            write!(formatter, "{}", self.name)?;
680            if let Some(line) = problem.line {
681                write!(formatter, ":{line}")?;
682                if let Some(column) = problem.column {
683                    write!(formatter, ":{column}")?;
684                }
685            }
686            if problem.field.is_empty() {
687                write!(formatter, ": {}", problem.message)?;
688            } else {
689                write!(formatter, ": {}: {}", problem.field, problem.message)?;
690            }
691        }
692        Ok(())
693    }
694}
695
696impl std::error::Error for InputError {}