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