Skip to main content

ctl_core/
input.rs

1//! Declared input: read a file, parse its shape with `serde-saphyr` (YAML) or
2//! the `toml` crate (TOML, with `input-toml`), validate it once with garde,
3//! and report every problem on its file and line.
4//!
5//! This is the one boundary where a CLI's config enters. Downstream code keeps
6//! the validated type and never checks it again.
7//!
8//! A text's [`Format`] is chosen, never guessed: [`Input::open`] reads it from
9//! the file's extension, and [`Input::new`] and [`Input::read`] are YAML.
10
11use std::borrow::Cow;
12use std::fmt;
13use std::path::Path;
14
15use garde::Validate;
16use serde::de::DeserializeOwned;
17use serde_saphyr::{
18    Budget, DefaultMessageFormatter, DuplicateKeyPolicy, Error, Localizer, Location, MergeKeyPolicy,
19};
20
21mod limits;
22#[cfg(feature = "input-toml")]
23mod toml_text;
24
25use limits::{Exceeded, Raise};
26pub use limits::{Limits, Loosened, REPO_FILE, set_default_limits, use_repo_limits};
27
28/// How a text is written.
29///
30/// Non-exhaustive: `Toml` exists only with `input-toml`, and Cargo unifies
31/// features across a build, so a consumer's `match` needs a wildcard arm.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum Format {
35    /// YAML, read by serde-saphyr and placed by yamled.
36    Yaml,
37    /// TOML, read and placed by the `toml` crate.
38    #[cfg(feature = "input-toml")]
39    Toml,
40}
41
42impl Format {
43    /// The format a file's extension names, compared without regard to case:
44    /// `.toml` is TOML, and `.yaml` and `.yml` are YAML.
45    ///
46    /// # Errors
47    ///
48    /// An [`InputError`] with no line, naming the extension and the accepted
49    /// ones, for any other extension or none; and for `.toml` when the
50    /// `input-toml` feature is off.
51    pub fn of(path: &Path) -> Result<Self, InputError> {
52        let name = path.display().to_string();
53        let extension = path
54            .extension()
55            .map(|extension| extension.to_string_lossy().to_ascii_lowercase());
56        match extension.as_deref() {
57            Some("yaml" | "yml") => Ok(Self::Yaml),
58            #[cfg(feature = "input-toml")]
59            Some("toml") => Ok(Self::Toml),
60            #[cfg(not(feature = "input-toml"))]
61            Some("toml") => Err(InputError::whole(
62                name,
63                format!("`.toml` needs ctl-core's input-toml feature; {ACCEPTED}"),
64            )),
65            Some(other) => Err(InputError::whole(
66                name,
67                format!("unsupported extension .{other}; {ACCEPTED}"),
68            )),
69            None => Err(InputError::whole(
70                name,
71                format!("no file extension; {ACCEPTED}"),
72            )),
73        }
74    }
75}
76
77/// What a refusal tells a person to name the file.
78#[cfg(feature = "input-toml")]
79const ACCEPTED: &str = "name the file *.toml, *.yaml, or *.yml";
80#[cfg(not(feature = "input-toml"))]
81const ACCEPTED: &str = "name the file *.yaml or *.yml";
82
83/// A named text, ready to parse and validate.
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct Input {
86    name: String,
87    text: String,
88    lines_before: usize,
89    limits: Limits,
90    format: Format,
91}
92
93/// Why an input was refused: every problem found, each placed on a line when
94/// the text allows it.
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct InputError {
97    /// The input's name, usually its path.
98    pub name: String,
99    /// What is wrong, in the order found.
100    pub problems: Vec<Problem>,
101}
102
103/// One thing wrong with an input.
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub struct Problem {
106    /// 1-based line, when known.
107    pub line: Option<usize>,
108    /// 1-based column, when known.
109    pub column: Option<usize>,
110    /// The field it concerns, as garde names it (`packages[1].name`); empty
111    /// for a problem with the whole text.
112    pub field: String,
113    /// What a person changes to fix it.
114    pub message: String,
115}
116
117impl Input {
118    /// A YAML input from text already in memory, whatever its name says. Set
119    /// another format with [`Input::format`].
120    #[must_use]
121    pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
122        Self {
123            name: name.into(),
124            text: text.into(),
125            lines_before: 0,
126            limits: Limits::NONE,
127            format: Format::Yaml,
128        }
129    }
130
131    /// Read a YAML file, named by its path, whatever its extension.
132    ///
133    /// # Errors
134    ///
135    /// An [`InputError`] with the I/O failure.
136    pub fn read(path: &Path) -> Result<Self, InputError> {
137        let name = path.display().to_string();
138        match std::fs::read_to_string(path) {
139            Ok(text) => Ok(Self::new(name, text)),
140            Err(error) => Err(InputError::whole(name, error.to_string())),
141        }
142    }
143
144    /// Read a file and take its [`Format`] from its extension, as
145    /// [`Format::of`] does. The extension is checked before the file is read.
146    ///
147    /// # Errors
148    ///
149    /// An [`InputError`] for an extension [`Format::of`] refuses, or with the
150    /// I/O failure.
151    pub fn open(path: &Path) -> Result<Self, InputError> {
152        let format = Format::of(path)?;
153        Ok(Self::read(path)?.format(format))
154    }
155
156    /// Set how the text is written, over what [`Input::new`] assumed.
157    #[must_use]
158    pub fn format(mut self, format: Format) -> Self {
159        self.format = format;
160        self
161    }
162
163    /// The input's name.
164    #[must_use]
165    pub fn name(&self) -> &str {
166        &self.name
167    }
168
169    /// The input's text.
170    #[must_use]
171    pub fn text(&self) -> &str {
172        &self.text
173    }
174
175    /// Split YAML frontmatter from a Markdown body: the text between a first
176    /// line of `---` and the next line of `---`. The frontmatter is YAML
177    /// whatever this input's format. Problems in the frontmatter keep the
178    /// line numbers of the whole file. `None` when the text does not start
179    /// with a `---` line, or never closes it.
180    #[must_use]
181    pub fn frontmatter(&self) -> Option<(Self, &str)> {
182        let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
183        let rest = text
184            .strip_prefix("---\n")
185            .or_else(|| text.strip_prefix("---\r\n"))?;
186        let mut offset = 0;
187        for line in rest.split_inclusive('\n') {
188            if line.trim_end() == "---" {
189                let matter = Self {
190                    name: self.name.clone(),
191                    text: rest[..offset].to_owned(),
192                    lines_before: self.lines_before + 1,
193                    limits: self.limits.clone(),
194                    format: Format::Yaml,
195                };
196                return Some((matter, &rest[offset + line.len()..]));
197            }
198            offset += line.len();
199        }
200        None
201    }
202
203    /// Override limits for this input alone, above every other tier: see
204    /// [`Limits`] for the order.
205    #[must_use]
206    pub fn limits(mut self, limits: Limits) -> Self {
207        self.limits = limits;
208        self
209    }
210
211    /// Parse the text's shape. In YAML only `true` and `false` are booleans,
212    /// and a duplicate key or a merge key (`<<`) is refused; in TOML a
213    /// duplicate key is refused by the format. The text must fit the limits in
214    /// force, field by field from this input, the repository's
215    /// [`REPO_FILE`], the process default, and serde-saphyr's own defaults.
216    /// TOML reads only `max_reader_input_bytes`, `max_depth`, and `max_nodes`
217    /// from them: the other fields name YAML syntax.
218    ///
219    /// # Errors
220    ///
221    /// An [`InputError`] with the parser's problem and its line. A limit
222    /// problem names the limit, its value, and where to raise it.
223    pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
224        self.parse_within(
225            &limits::effective(&self.limits).budget(),
226            Raise::InRepository,
227        )
228    }
229
230    /// [`Self::parse`] under exactly `budget`, whatever the tiers say.
231    fn parse_within<T: DeserializeOwned>(
232        &self,
233        budget: &Budget,
234        raise: Raise,
235    ) -> Result<T, InputError> {
236        if let Some(limit) = budget.max_reader_input_bytes
237            && self.text.len() > limit
238        {
239            return Err(InputError::whole(
240                self.name.clone(),
241                format!(
242                    "is {} bytes, which {}",
243                    self.text.len(),
244                    Exceeded::input_bytes(limit, raise)
245                ),
246            ));
247        }
248        match self.format {
249            Format::Yaml => self.parse_yaml(budget, raise),
250            #[cfg(feature = "input-toml")]
251            Format::Toml => toml_text::parse(self, budget, raise),
252        }
253    }
254
255    /// [`Self::parse_within`] for YAML, once the text's size is known to fit.
256    fn parse_yaml<T: DeserializeOwned>(
257        &self,
258        budget: &Budget,
259        raise: Raise,
260    ) -> Result<T, InputError> {
261        let options = serde_saphyr::options! {
262            budget: Some(budget.clone()),
263            strict_booleans: true,
264            duplicate_keys: DuplicateKeyPolicy::Error,
265            merge_keys: MergeKeyPolicy::Error,
266            with_snippet: false,
267        };
268        serde_saphyr::from_str_with_options(&self.text, options).map_err(|error| {
269            let location = error.location();
270            InputError {
271                name: self.name.clone(),
272                problems: vec![Problem {
273                    line: location
274                        .and_then(|at| usize::try_from(at.line()).ok())
275                        .map(|line| line + self.lines_before),
276                    column: location.and_then(|at| usize::try_from(at.column()).ok()),
277                    field: String::new(),
278                    message: self.message(error.without_snippet(), budget, raise),
279                }],
280            }
281        })
282    }
283
284    /// serde-saphyr's wording for `error`. An alias error also names where its
285    /// value is defined, counted from the file like [`Problem::line`], and a
286    /// limit error names the limit in `budget` and where to raise it.
287    fn message(&self, error: &Error, budget: &Budget, raise: Raise) -> String {
288        if let Error::Budget { breach, .. } = error
289            && let Some(exceeded) = Exceeded::breach(breach, budget, raise)
290        {
291            return exceeded.to_string();
292        }
293        let Error::AliasError { msg, locations } = error else {
294            return error.render_with_formatter(&DefaultMessageFormatter.with_localizer(&Unplaced));
295        };
296        let defined = locations.defined_location;
297        let (text, innermost) = baked_positions(msg);
298        let (line, column) = innermost.unwrap_or((
299            usize::try_from(defined.line()).unwrap_or_default(),
300            usize::try_from(defined.column()).unwrap_or_default(),
301        ));
302        let line = line + self.lines_before;
303        format!("{text} (defined at line {line}, column {column})")
304    }
305
306    /// Validate a parsed value against `context`, and place each of garde's
307    /// problems on the line of the field it names, or of its nearest written
308    /// ancestor.
309    ///
310    /// # Errors
311    ///
312    /// An [`InputError`] with every problem garde reports.
313    pub fn check<T: Validate + ?Sized>(
314        &self,
315        value: &T,
316        context: &T::Context,
317    ) -> Result<(), InputError> {
318        let Err(report) = value.validate_with(context) else {
319            return Ok(());
320        };
321        let reported: Vec<_> = report
322            .iter()
323            .map(|(path, error)| (path.to_string(), error.message().to_owned()))
324            .collect();
325        let fields: Vec<_> = reported.iter().map(|(field, _)| field.as_str()).collect();
326        let places = self.places(&fields);
327        let problems = reported
328            .into_iter()
329            .zip(places)
330            .map(|((field, message), place)| Problem {
331                line: place.map(|(line, _)| line + self.lines_before),
332                column: place.map(|(_, column)| column),
333                field,
334                message,
335            })
336            .collect();
337        Err(InputError {
338            name: self.name.clone(),
339            problems,
340        })
341    }
342
343    /// The 1-based line and column in the text of each garde field, or of its
344    /// nearest written ancestor, counted in the text itself.
345    fn places(&self, fields: &[&str]) -> Vec<Option<(usize, usize)>> {
346        match self.format {
347            Format::Yaml => {
348                let document = yamled::Document::parse(self.text.as_str()).ok();
349                fields
350                    .iter()
351                    .map(|field| {
352                        document
353                            .as_ref()
354                            .and_then(|document| {
355                                document.locate_nearest(&yaml_path(&field_steps(field)))
356                            })
357                            .map(|at| (at.line, at.column))
358                    })
359                    .collect()
360            }
361            #[cfg(feature = "input-toml")]
362            Format::Toml => toml_text::places(&self.text, fields),
363        }
364    }
365
366    /// Parse and validate with the default context.
367    ///
368    /// # Errors
369    ///
370    /// As [`Input::parse`] and [`Input::check`].
371    pub fn load<T>(&self) -> Result<T, InputError>
372    where
373        T: DeserializeOwned + Validate,
374        T::Context: Default,
375    {
376        let value = self.parse::<T>()?;
377        self.check(&value, &T::Context::default())?;
378        Ok(value)
379    }
380}
381
382/// serde-saphyr's wording without the position it appends. That position
383/// counts from the parsed text, which for frontmatter is not the file, and
384/// [`Problem`] already carries the file's line and column.
385struct Unplaced;
386
387impl Localizer for Unplaced {
388    fn attach_location<'a>(&self, base: Cow<'a, str>, _: Location) -> Cow<'a, str> {
389        base
390    }
391}
392
393/// The text of an [`Error::AliasError`] without the positions serde-saphyr
394/// 1.3.0 wrote into it, and the innermost ` at line` one: where the failing
395/// value is.
396/// That text is rendered in English before any localizer runs, and an alias
397/// reached through another alias nests one rendered error inside the next, so
398/// each layer ends in ` (defined at line N, column M)`, ` at line N, column M`,
399/// or both. Remove this once serde-saphyr keeps the inner error structured.
400fn baked_positions(msg: &str) -> (&str, Option<(usize, usize)>) {
401    let mut text = msg;
402    let mut innermost = None;
403    loop {
404        if let Some((rest, _)) = trailing_position(text, " (defined at line ", ")") {
405            text = rest;
406        } else if let Some((rest, at)) = trailing_position(text, " at line ", "") {
407            text = rest;
408            innermost = Some(at);
409        } else {
410            break;
411        }
412    }
413    (text, innermost)
414}
415
416/// `text` without a last `{lead}N, column M{close}`, with `N` and `M`.
417fn trailing_position<'a>(
418    text: &'a str,
419    lead: &str,
420    close: &str,
421) -> Option<(&'a str, (usize, usize))> {
422    let (rest, position) = text.strip_suffix(close)?.rsplit_once(lead)?;
423    let (line, column) = position.split_once(", column ")?;
424    Some((rest, (line.parse().ok()?, column.parse().ok()?)))
425}
426
427/// One step down a garde field path.
428enum Step<'field> {
429    /// A key of a mapping or table.
430    Key(&'field str),
431    /// An item of a sequence or array.
432    Index(usize),
433}
434
435/// A garde field path (`packages[1].name`) as the steps down to it. A key
436/// holding `.` or `[` cannot be told apart from nesting, so such a problem
437/// lands on the nearest ancestor that resolves.
438fn field_steps(field: &str) -> Vec<Step<'_>> {
439    let mut steps = Vec::new();
440    for part in field.split('.') {
441        let mut pieces = part.split('[');
442        if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
443            steps.push(Step::Key(key));
444        }
445        for index in pieces {
446            let index = index.trim_end_matches(']');
447            steps.push(match index.parse::<usize>() {
448                Ok(index) => Step::Index(index),
449                Err(_) => Step::Key(index),
450            });
451        }
452    }
453    steps
454}
455
456/// `steps` as a yamled path.
457fn yaml_path(steps: &[Step<'_>]) -> yamled::Path {
458    steps
459        .iter()
460        .fold(yamled::Path::root(), |path, step| match step {
461            Step::Key(key) => path.key(*key),
462            Step::Index(index) => path.index(*index),
463        })
464}
465
466impl InputError {
467    fn whole(name: String, message: String) -> Self {
468        Self {
469            name,
470            problems: vec![Problem {
471                line: None,
472                column: None,
473                field: String::new(),
474                message,
475            }],
476        }
477    }
478}
479
480/// One problem per line: `name:line:column: field: message`, leaving out
481/// what is not known.
482impl fmt::Display for InputError {
483    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
484        for (index, problem) in self.problems.iter().enumerate() {
485            if index > 0 {
486                writeln!(formatter)?;
487            }
488            write!(formatter, "{}", self.name)?;
489            if let Some(line) = problem.line {
490                write!(formatter, ":{line}")?;
491                if let Some(column) = problem.column {
492                    write!(formatter, ":{column}")?;
493                }
494            }
495            if problem.field.is_empty() {
496                write!(formatter, ": {}", problem.message)?;
497            } else {
498                write!(formatter, ": {}: {}", problem.field, problem.message)?;
499            }
500        }
501        Ok(())
502    }
503}
504
505impl std::error::Error for InputError {}
506
507#[cfg(test)]
508mod tests {
509    use super::baked_positions;
510
511    #[test]
512    fn every_written_position_is_taken_off_the_text() {
513        assert_eq!(
514            baked_positions("invalid u16 (defined at line 2, column 3)"),
515            ("invalid u16", None)
516        );
517        assert_eq!(
518            baked_positions(
519                "invalid u16 at line 2, column 9 (defined at line 2, column 9) at line 3, column 7"
520            ),
521            ("invalid u16", Some((2, 9)))
522        );
523    }
524}