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 text's [`Format`] is chosen, never guessed. [`Input::open`] reads it from
12//! the file's extension, and [`Input::new_with_format`] takes it for text in
13//! memory. With `input-yaml`, `Input::new` and `Input::read` are YAML.
14//!
15//! ```
16//! # #[cfg(feature = "input")]
17//! # {
18//! use ctl_core::input::Input;
19//! use serde::Deserialize;
20//!
21//! #[derive(Debug, Deserialize)]
22//! struct Config {
23//!     name: String,
24//! }
25//!
26//! let config: Config = Input::new("toy.yaml", "name: release\n").parse()?;
27//! assert_eq!(config.name, "release");
28//!
29//! let error = Input::new("toy.yaml", "name: a\nname: b\n")
30//!     .parse::<Config>()
31//!     .unwrap_err();
32//! assert!(error.to_string().starts_with("toy.yaml"));
33//! # }
34//! # Ok::<(), ctl_core::input::InputError>(())
35//! ```
36
37use std::path::Path;
38use std::{fmt, io};
39
40#[cfg(feature = "validate")]
41use garde::Validate;
42use serde::de::DeserializeOwned;
43
44mod limits;
45#[cfg(feature = "input-toml")]
46mod toml_text;
47#[cfg(feature = "input-yaml")]
48mod yaml_text;
49
50use limits::{Exceeded, Raise};
51pub use limits::{Limits, set_default_limits};
52#[cfg(feature = "input-yaml")]
53pub use limits::{Loosened, REPO_FILE, use_repo_limits};
54
55/// How a text is written.
56///
57/// Non-exhaustive: each variant exists only with its format's feature.
58/// Cargo unifies features across a build, so a caller's `match` needs a
59/// wildcard arm.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum Format {
63    /// YAML, read by serde-saphyr and placed by yamled.
64    #[cfg(feature = "input-yaml")]
65    Yaml,
66    /// TOML, read and placed by the `toml` crate.
67    #[cfg(feature = "input-toml")]
68    Toml,
69}
70
71impl Format {
72    /// The format a file's extension names, compared without regard to case:
73    /// `.toml` is TOML, and `.yaml` and `.yml` are YAML.
74    ///
75    /// # Errors
76    ///
77    /// An [`InputError`] with no line for any other extension or none, naming
78    /// the extension and the accepted ones. One naming the feature for an
79    /// extension whose format's feature is off.
80    pub fn of(path: &Path) -> Result<Self, InputError> {
81        let name = path.display().to_string();
82        let extension = path
83            .extension()
84            .map(|extension| extension.to_string_lossy().to_ascii_lowercase());
85        match extension.as_deref() {
86            #[cfg(feature = "input-yaml")]
87            Some("yaml" | "yml") => Ok(Self::Yaml),
88            #[cfg(not(feature = "input-yaml"))]
89            Some(yaml @ ("yaml" | "yml")) => Err(InputError::whole(
90                name,
91                format!("`.{yaml}` needs ctl-core's input-yaml feature; {ACCEPTED}"),
92            )),
93            #[cfg(feature = "input-toml")]
94            Some("toml") => Ok(Self::Toml),
95            #[cfg(not(feature = "input-toml"))]
96            Some("toml") => Err(InputError::whole(
97                name,
98                format!("`.toml` needs ctl-core's input-toml feature; {ACCEPTED}"),
99            )),
100            Some(other) => Err(InputError::whole(
101                name,
102                format!("unsupported extension .{other}; {ACCEPTED}"),
103            )),
104            None => Err(InputError::whole(
105                name,
106                format!("no file extension; {ACCEPTED}"),
107            )),
108        }
109    }
110}
111
112/// What a refusal tells a person to name the file: the extensions this build
113/// reads.
114#[cfg(all(feature = "input-yaml", feature = "input-toml"))]
115const ACCEPTED: &str = "name the file *.toml, *.yaml, or *.yml";
116#[cfg(all(feature = "input-yaml", not(feature = "input-toml")))]
117const ACCEPTED: &str = "name the file *.yaml or *.yml";
118#[cfg(all(feature = "input-toml", not(feature = "input-yaml")))]
119const ACCEPTED: &str = "name the file *.toml";
120
121/// A named text, ready to parse and validate.
122///
123/// The name is usually the file's path, and appears in every problem. Build
124/// one with [`Input::new_with_format`] for text in memory, or [`Input::open`]
125/// for a file. With `input-yaml`, `Input::new` and `Input::read` build a YAML
126/// one.
127#[derive(Clone, Debug, PartialEq, Eq)]
128pub struct Input {
129    name: String,
130    text: String,
131    lines_before: usize,
132    limits: Limits,
133    format: Format,
134}
135
136/// Why an input was refused: every problem found, each placed on a line when
137/// the text allows it.
138///
139/// `Display` prints one problem per line as `name:line:column: field: message`,
140/// leaving out what is not known.
141///
142/// A file that could not be read keeps its [`io::ErrorKind`], which
143/// [`Self::io_failure`] returns. So a caller can treat a missing file apart
144/// from a refused one. A field can join this type in a minor release.
145#[derive(Clone, Debug, PartialEq, Eq)]
146#[non_exhaustive]
147pub struct InputError {
148    /// The input's name, usually its path.
149    pub name: String,
150    /// What is wrong, in the order found.
151    pub problems: Vec<Problem>,
152    /// The I/O failure, for an input that could not be read.
153    io_failure: Option<io::ErrorKind>,
154}
155
156/// One thing wrong with an input.
157#[derive(Clone, Debug, PartialEq, Eq)]
158pub struct Problem {
159    /// 1-based line, when known.
160    pub line: Option<usize>,
161    /// 1-based column, when known.
162    pub column: Option<usize>,
163    /// The field it concerns, as garde names it (`packages[1].name`); empty
164    /// for a problem with the whole text.
165    pub field: String,
166    /// What a person changes to fix it.
167    pub message: String,
168}
169
170impl Input {
171    /// A YAML input from text already in memory, whatever the extension of
172    /// `name`. Set another format with [`Input::format`].
173    #[cfg(feature = "input-yaml")]
174    #[must_use]
175    pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
176        Self::new_with_format(name, text, Format::Yaml)
177    }
178
179    /// An input of `format` from text already in memory, whatever the
180    /// extension of `name`. It needs only `format`'s feature, so a build
181    /// without YAML builds text in memory with it.
182    #[must_use]
183    pub fn new_with_format(
184        name: impl Into<String>,
185        text: impl Into<String>,
186        format: Format,
187    ) -> Self {
188        Self {
189            name: name.into(),
190            text: text.into(),
191            lines_before: 0,
192            limits: Limits::NONE,
193            format,
194        }
195    }
196
197    /// Read a YAML file, named by its path, whatever its extension.
198    ///
199    /// # Errors
200    ///
201    /// An [`InputError`] with the I/O failure.
202    #[cfg(feature = "input-yaml")]
203    pub fn read(path: &Path) -> Result<Self, InputError> {
204        let (name, text) = read_text(path)?;
205        Ok(Self::new(name, text))
206    }
207
208    /// Read a file and take its [`Format`] from its extension, as
209    /// [`Format::of`] does. The extension is checked before the file is read.
210    ///
211    /// # Errors
212    ///
213    /// An [`InputError`] for an extension [`Format::of`] refuses, or with the
214    /// I/O failure.
215    pub fn open(path: &Path) -> Result<Self, InputError> {
216        let format = Format::of(path)?;
217        let (name, text) = read_text(path)?;
218        Ok(Self::new_with_format(name, text, format))
219    }
220
221    /// Set how the text is written, over what the constructor took.
222    #[must_use]
223    pub fn format(mut self, format: Format) -> Self {
224        self.format = format;
225        self
226    }
227
228    /// The name given at construction, usually the file's path.
229    #[must_use]
230    pub fn name(&self) -> &str {
231        &self.name
232    }
233
234    /// The text as read, unchanged.
235    #[must_use]
236    pub fn text(&self) -> &str {
237        &self.text
238    }
239
240    /// Split YAML frontmatter from a Markdown body.
241    ///
242    /// The frontmatter is the text between a first line of `---` and the next
243    /// line of `---`. It is YAML whatever this input's format, and problems in
244    /// it keep the line numbers of the whole file. The second item is the body
245    /// after the closing line. Returns `None` when the text does not start with
246    /// a `---` line, or never closes it.
247    #[cfg(feature = "input-yaml")]
248    #[must_use]
249    pub fn frontmatter(&self) -> Option<(Self, &str)> {
250        let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
251        let rest = text
252            .strip_prefix("---\n")
253            .or_else(|| text.strip_prefix("---\r\n"))?;
254        let mut offset = 0;
255        for line in rest.split_inclusive('\n') {
256            if line.trim_end() == "---" {
257                let matter = Self {
258                    name: self.name.clone(),
259                    text: rest[..offset].to_owned(),
260                    lines_before: self.lines_before + 1,
261                    limits: self.limits.clone(),
262                    format: Format::Yaml,
263                };
264                return Some((matter, &rest[offset + line.len()..]));
265            }
266            offset += line.len();
267        }
268        None
269    }
270
271    /// Override limits for this input alone, above every other tier. See
272    /// [`Limits`] for the order.
273    #[must_use]
274    pub fn limits(mut self, limits: Limits) -> Self {
275        self.limits = limits;
276        self
277    }
278
279    /// Parse the text into `T`, without validating it.
280    ///
281    /// In YAML only `true` and `false` are booleans, and a duplicate key or a
282    /// merge key (`<<`) is refused. In TOML the format itself refuses a
283    /// duplicate key.
284    ///
285    /// The text must fit the limits in force, decided field by field through
286    /// the tiers [`Limits`] lists. TOML reads only `max_reader_input_bytes`,
287    /// `max_depth`, and `max_nodes`, because the other fields name YAML
288    /// syntax.
289    ///
290    /// # Errors
291    ///
292    /// An [`InputError`] with the parser's problem and its line. A limit
293    /// problem names the limit, its value, and where to raise it, or, without
294    /// `input-yaml`, that the tool sets it.
295    pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
296        self.parse_within(&limits::effective(&self.limits), Raise::FOR_TEXT)
297    }
298
299    /// [`Self::parse`] under exactly `limits`, whatever the tiers say.
300    fn parse_within<T: DeserializeOwned>(
301        &self,
302        limits: &Limits,
303        raise: Raise,
304    ) -> Result<T, InputError> {
305        let bounds = limits.bounds();
306        if self.text.len() > bounds.input_bytes {
307            return Err(InputError::whole(
308                self.name.clone(),
309                format!(
310                    "is {} bytes, which {}",
311                    self.text.len(),
312                    Exceeded::input_bytes(bounds.input_bytes, raise)
313                ),
314            ));
315        }
316        match self.format {
317            #[cfg(feature = "input-yaml")]
318            Format::Yaml => yaml_text::parse(self, &limits.budget(), raise),
319            #[cfg(feature = "input-toml")]
320            Format::Toml => toml_text::parse(self, bounds, raise),
321        }
322    }
323
324    /// Validate a parsed `value` against `context`.
325    ///
326    /// Each of garde's problems lands on the line of the field it names, or
327    /// of its nearest written ancestor.
328    ///
329    /// # Errors
330    ///
331    /// An [`InputError`] with every problem garde reports.
332    #[cfg(feature = "validate")]
333    pub fn check<T: Validate + ?Sized>(
334        &self,
335        value: &T,
336        context: &T::Context,
337    ) -> Result<(), InputError> {
338        let Err(report) = value.validate_with(context) else {
339            return Ok(());
340        };
341        let reported: Vec<_> = report
342            .iter()
343            .map(|(path, error)| (path.to_string(), error.message().to_owned()))
344            .collect();
345        let fields: Vec<_> = reported.iter().map(|(field, _)| field.as_str()).collect();
346        let places = self.places(&fields);
347        let problems = reported
348            .into_iter()
349            .zip(places)
350            .map(|((field, message), place)| Problem {
351                line: place.map(|(line, _)| line + self.lines_before),
352                column: place.map(|(_, column)| column),
353                field,
354                message,
355            })
356            .collect();
357        Err(InputError::of(self.name.clone(), problems))
358    }
359
360    /// The 1-based line and column in the text of each garde field, or of its
361    /// nearest written ancestor, counted in the text itself.
362    #[cfg(feature = "validate")]
363    fn places(&self, fields: &[&str]) -> Vec<Option<(usize, usize)>> {
364        match self.format {
365            #[cfg(feature = "input-yaml")]
366            Format::Yaml => yaml_text::places(&self.text, fields),
367            #[cfg(feature = "input-toml")]
368            Format::Toml => toml_text::places(&self.text, fields),
369        }
370    }
371
372    /// Parse the text into `T`, then validate it with `T`'s default context.
373    ///
374    /// # Errors
375    ///
376    /// As [`Input::parse`] and [`Input::check`].
377    #[cfg(feature = "validate")]
378    pub fn load<T>(&self) -> Result<T, InputError>
379    where
380        T: DeserializeOwned + Validate,
381        T::Context: Default,
382    {
383        let value = self.parse::<T>()?;
384        self.check(&value, &T::Context::default())?;
385        Ok(value)
386    }
387}
388
389/// The file at `path`, named by its path, or the I/O failure.
390fn read_text(path: &Path) -> Result<(String, String), InputError> {
391    let name = path.display().to_string();
392    match std::fs::read_to_string(path) {
393        Ok(text) => Ok((name, text)),
394        Err(error) => Err(InputError::unread(name, &error)),
395    }
396}
397
398/// One step down a garde field path.
399#[cfg(feature = "validate")]
400enum Step<'field> {
401    /// A key of a mapping or table.
402    Key(&'field str),
403    /// An item of a sequence or array.
404    Index(usize),
405}
406
407/// A garde field path (`packages[1].name`) as the steps down to it. A key
408/// holding `.` or `[` cannot be told apart from nesting, so such a problem
409/// lands on the nearest ancestor that resolves.
410#[cfg(feature = "validate")]
411fn field_steps(field: &str) -> Vec<Step<'_>> {
412    let mut steps = Vec::new();
413    for part in field.split('.') {
414        let mut pieces = part.split('[');
415        if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
416            steps.push(Step::Key(key));
417        }
418        for index in pieces {
419            let index = index.trim_end_matches(']');
420            steps.push(match index.parse::<usize>() {
421                Ok(index) => Step::Index(index),
422                Err(_) => Step::Key(index),
423            });
424        }
425    }
426    steps
427}
428
429impl InputError {
430    /// A refusal of the whole input, not placed on a line.
431    fn whole(name: String, message: String) -> Self {
432        Self::of(
433            name,
434            vec![Problem {
435                line: None,
436                column: None,
437                field: String::new(),
438                message,
439            }],
440        )
441    }
442
443    /// The input `name` could not be read: the failure's text is the one
444    /// problem, and its `io::ErrorKind` is kept.
445    fn unread(name: String, error: &io::Error) -> Self {
446        Self {
447            io_failure: Some(error.kind()),
448            ..Self::whole(name, error.to_string())
449        }
450    }
451
452    /// The problems found in the input `name`, with no I/O failure.
453    const fn of(name: String, problems: Vec<Problem>) -> Self {
454        Self {
455            name,
456            problems,
457            io_failure: None,
458        }
459    }
460
461    /// The I/O failure when the file could not be read, such as
462    /// [`io::ErrorKind::NotFound`]. `None` when the text was read and refused.
463    #[must_use]
464    pub const fn io_failure(&self) -> Option<io::ErrorKind> {
465        self.io_failure
466    }
467}
468
469/// One problem per line: `name:line:column: field: message`, leaving out
470/// what is not known.
471impl fmt::Display for InputError {
472    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
473        for (index, problem) in self.problems.iter().enumerate() {
474            if index > 0 {
475                writeln!(formatter)?;
476            }
477            write!(formatter, "{}", self.name)?;
478            if let Some(line) = problem.line {
479                write!(formatter, ":{line}")?;
480                if let Some(column) = problem.column {
481                    write!(formatter, ":{column}")?;
482                }
483            }
484            if problem.field.is_empty() {
485                write!(formatter, ": {}", problem.message)?;
486            } else {
487                write!(formatter, ": {}: {}", problem.field, problem.message)?;
488            }
489        }
490        Ok(())
491    }
492}
493
494impl std::error::Error for InputError {}