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    ///
288    /// For an alias error the wording and position are those of the error
289    /// the aliased value raised, found under any alias reached through
290    /// another alias; without a known position, the anchor's.
291    fn message(&self, error: &Error, budget: &Budget, raise: Raise) -> String {
292        if let Error::Budget { breach, .. } = error
293            && let Some(exceeded) = Exceeded::breach(breach, budget, raise)
294        {
295            return exceeded.to_string();
296        }
297        let formatter = DefaultMessageFormatter.with_localizer(&Unplaced);
298        let Error::AliasError {
299            error: inner,
300            locations,
301            ..
302        } = error
303        else {
304            return error.render_with_formatter(&formatter);
305        };
306        let mut failing = inner.as_ref();
307        while let Error::AliasError { error, .. } | Error::WithSnippet { error, .. } = failing {
308            failing = error;
309        }
310        let at = failing
311            .location()
312            .filter(|at| *at != Location::UNKNOWN)
313            .unwrap_or(locations.defined_location);
314        let line = usize::try_from(at.line()).unwrap_or_default() + self.lines_before;
315        let column = at.column();
316        let text = failing.render_with_formatter(&formatter);
317        format!("{text} (defined at line {line}, column {column})")
318    }
319
320    /// Validate a parsed value against `context`, and place each of garde's
321    /// problems on the line of the field it names, or of its nearest written
322    /// ancestor.
323    ///
324    /// # Errors
325    ///
326    /// An [`InputError`] with every problem garde reports.
327    pub fn check<T: Validate + ?Sized>(
328        &self,
329        value: &T,
330        context: &T::Context,
331    ) -> Result<(), InputError> {
332        let Err(report) = value.validate_with(context) else {
333            return Ok(());
334        };
335        let reported: Vec<_> = report
336            .iter()
337            .map(|(path, error)| (path.to_string(), error.message().to_owned()))
338            .collect();
339        let fields: Vec<_> = reported.iter().map(|(field, _)| field.as_str()).collect();
340        let places = self.places(&fields);
341        let problems = reported
342            .into_iter()
343            .zip(places)
344            .map(|((field, message), place)| Problem {
345                line: place.map(|(line, _)| line + self.lines_before),
346                column: place.map(|(_, column)| column),
347                field,
348                message,
349            })
350            .collect();
351        Err(InputError {
352            name: self.name.clone(),
353            problems,
354        })
355    }
356
357    /// The 1-based line and column in the text of each garde field, or of its
358    /// nearest written ancestor, counted in the text itself.
359    fn places(&self, fields: &[&str]) -> Vec<Option<(usize, usize)>> {
360        match self.format {
361            Format::Yaml => {
362                let document = yamled::Document::parse(self.text.as_str()).ok();
363                fields
364                    .iter()
365                    .map(|field| {
366                        document
367                            .as_ref()
368                            .and_then(|document| {
369                                document.locate_nearest(&yaml_path(&field_steps(field)))
370                            })
371                            .map(|at| (at.line, at.column))
372                    })
373                    .collect()
374            }
375            #[cfg(feature = "input-toml")]
376            Format::Toml => toml_text::places(&self.text, fields),
377        }
378    }
379
380    /// Parse and validate with the default context.
381    ///
382    /// # Errors
383    ///
384    /// As [`Input::parse`] and [`Input::check`].
385    pub fn load<T>(&self) -> Result<T, InputError>
386    where
387        T: DeserializeOwned + Validate,
388        T::Context: Default,
389    {
390        let value = self.parse::<T>()?;
391        self.check(&value, &T::Context::default())?;
392        Ok(value)
393    }
394}
395
396/// serde-saphyr's wording without the position it appends. That position
397/// counts from the parsed text, which for frontmatter is not the file, and
398/// [`Problem`] already carries the file's line and column.
399struct Unplaced;
400
401impl Localizer for Unplaced {
402    fn attach_location<'a>(&self, base: Cow<'a, str>, _: Location) -> Cow<'a, str> {
403        base
404    }
405
406    fn alias_used_at(&self, _: Location) -> String {
407        String::new()
408    }
409}
410
411/// One step down a garde field path.
412enum Step<'field> {
413    /// A key of a mapping or table.
414    Key(&'field str),
415    /// An item of a sequence or array.
416    Index(usize),
417}
418
419/// A garde field path (`packages[1].name`) as the steps down to it. A key
420/// holding `.` or `[` cannot be told apart from nesting, so such a problem
421/// lands on the nearest ancestor that resolves.
422fn field_steps(field: &str) -> Vec<Step<'_>> {
423    let mut steps = Vec::new();
424    for part in field.split('.') {
425        let mut pieces = part.split('[');
426        if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
427            steps.push(Step::Key(key));
428        }
429        for index in pieces {
430            let index = index.trim_end_matches(']');
431            steps.push(match index.parse::<usize>() {
432                Ok(index) => Step::Index(index),
433                Err(_) => Step::Key(index),
434            });
435        }
436    }
437    steps
438}
439
440/// `steps` as a yamled path.
441fn yaml_path(steps: &[Step<'_>]) -> yamled::Path {
442    steps
443        .iter()
444        .fold(yamled::Path::root(), |path, step| match step {
445            Step::Key(key) => path.key(*key),
446            Step::Index(index) => path.index(*index),
447        })
448}
449
450impl InputError {
451    fn whole(name: String, message: String) -> Self {
452        Self {
453            name,
454            problems: vec![Problem {
455                line: None,
456                column: None,
457                field: String::new(),
458                message,
459            }],
460        }
461    }
462}
463
464/// One problem per line: `name:line:column: field: message`, leaving out
465/// what is not known.
466impl fmt::Display for InputError {
467    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
468        for (index, problem) in self.problems.iter().enumerate() {
469            if index > 0 {
470                writeln!(formatter)?;
471            }
472            write!(formatter, "{}", self.name)?;
473            if let Some(line) = problem.line {
474                write!(formatter, ":{line}")?;
475                if let Some(column) = problem.column {
476                    write!(formatter, ":{column}")?;
477                }
478            }
479            if problem.field.is_empty() {
480                write!(formatter, ": {}", problem.message)?;
481            } else {
482                write!(formatter, ": {}: {}", problem.field, problem.message)?;
483            }
484        }
485        Ok(())
486    }
487}
488
489impl std::error::Error for InputError {}