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