Skip to main content

ctl_core/
input.rs

1//! Declared input: read a file, parse its shape with `serde-saphyr`, validate
2//! it once with garde, and report every problem on its file and line.
3//!
4//! This is the one boundary where a CLI's config enters. Downstream code keeps
5//! the validated type and never checks it again.
6
7use std::borrow::Cow;
8use std::fmt;
9use std::path::Path;
10
11use garde::Validate;
12use serde::de::DeserializeOwned;
13use serde_saphyr::{
14    Budget, DefaultMessageFormatter, DuplicateKeyPolicy, Error, Localizer, Location, MergeKeyPolicy,
15};
16
17mod limits;
18
19use limits::{Exceeded, Raise};
20pub use limits::{Limits, Loosened, REPO_FILE, set_default_limits, use_repo_limits};
21
22/// A named YAML text, ready to parse and validate.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct Input {
25    name: String,
26    text: String,
27    lines_before: usize,
28    limits: Limits,
29}
30
31/// Why an input was refused: every problem found, each placed on a line when
32/// the text allows it.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct InputError {
35    /// The input's name, usually its path.
36    pub name: String,
37    /// What is wrong, in the order found.
38    pub problems: Vec<Problem>,
39}
40
41/// One thing wrong with an input.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct Problem {
44    /// 1-based line, when known.
45    pub line: Option<usize>,
46    /// 1-based column, when known.
47    pub column: Option<usize>,
48    /// The field it concerns, as garde names it (`packages[1].name`); empty
49    /// for a problem with the whole text.
50    pub field: String,
51    /// What a person changes to fix it.
52    pub message: String,
53}
54
55impl Input {
56    /// An input from text already in memory.
57    #[must_use]
58    pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
59        Self {
60            name: name.into(),
61            text: text.into(),
62            lines_before: 0,
63            limits: Limits::NONE,
64        }
65    }
66
67    /// Read a file, named by its path.
68    ///
69    /// # Errors
70    ///
71    /// An [`InputError`] with the I/O failure.
72    pub fn read(path: &Path) -> Result<Self, InputError> {
73        let name = path.display().to_string();
74        match std::fs::read_to_string(path) {
75            Ok(text) => Ok(Self::new(name, text)),
76            Err(error) => Err(InputError::whole(name, error.to_string())),
77        }
78    }
79
80    /// The input's name.
81    #[must_use]
82    pub fn name(&self) -> &str {
83        &self.name
84    }
85
86    /// The input's text.
87    #[must_use]
88    pub fn text(&self) -> &str {
89        &self.text
90    }
91
92    /// Split YAML frontmatter from a Markdown body: the text between a first
93    /// line of `---` and the next line of `---`. Problems in the frontmatter
94    /// keep the line numbers of the whole file. `None` when the text does not
95    /// start with a `---` line, or never closes it.
96    #[must_use]
97    pub fn frontmatter(&self) -> Option<(Self, &str)> {
98        let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
99        let rest = text
100            .strip_prefix("---\n")
101            .or_else(|| text.strip_prefix("---\r\n"))?;
102        let mut offset = 0;
103        for line in rest.split_inclusive('\n') {
104            if line.trim_end() == "---" {
105                let matter = Self {
106                    name: self.name.clone(),
107                    text: rest[..offset].to_owned(),
108                    lines_before: self.lines_before + 1,
109                    limits: self.limits.clone(),
110                };
111                return Some((matter, &rest[offset + line.len()..]));
112            }
113            offset += line.len();
114        }
115        None
116    }
117
118    /// Override limits for this input alone, above every other tier: see
119    /// [`Limits`] for the order.
120    #[must_use]
121    pub fn limits(mut self, limits: Limits) -> Self {
122        self.limits = limits;
123        self
124    }
125
126    /// Parse the text's shape. Only `true` and `false` are booleans, and a
127    /// duplicate key or a merge key (`<<`) is refused. The text must fit the
128    /// limits in force, field by field from this input, the repository's
129    /// [`REPO_FILE`], the process default, and serde-saphyr's own defaults.
130    ///
131    /// # Errors
132    ///
133    /// An [`InputError`] with the parser's problem and its line. A limit
134    /// problem names the limit, its value, and where to raise it.
135    pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
136        self.parse_within(
137            &limits::effective(&self.limits).budget(),
138            Raise::InRepository,
139        )
140    }
141
142    /// [`Self::parse`] under exactly `budget`, whatever the tiers say.
143    fn parse_within<T: DeserializeOwned>(
144        &self,
145        budget: &Budget,
146        raise: Raise,
147    ) -> Result<T, InputError> {
148        if let Some(limit) = budget.max_reader_input_bytes
149            && self.text.len() > limit
150        {
151            return Err(InputError::whole(
152                self.name.clone(),
153                format!(
154                    "is {} bytes, which {}",
155                    self.text.len(),
156                    Exceeded::input_bytes(limit, raise)
157                ),
158            ));
159        }
160        let options = serde_saphyr::options! {
161            budget: Some(budget.clone()),
162            strict_booleans: true,
163            duplicate_keys: DuplicateKeyPolicy::Error,
164            merge_keys: MergeKeyPolicy::Error,
165            with_snippet: false,
166        };
167        serde_saphyr::from_str_with_options(&self.text, options).map_err(|error| {
168            let location = error.location();
169            InputError {
170                name: self.name.clone(),
171                problems: vec![Problem {
172                    line: location
173                        .and_then(|at| usize::try_from(at.line()).ok())
174                        .map(|line| line + self.lines_before),
175                    column: location.and_then(|at| usize::try_from(at.column()).ok()),
176                    field: String::new(),
177                    message: self.message(error.without_snippet(), budget, raise),
178                }],
179            }
180        })
181    }
182
183    /// serde-saphyr's wording for `error`. An alias error also names where its
184    /// value is defined, counted from the file like [`Problem::line`], and a
185    /// limit error names the limit in `budget` and where to raise it.
186    fn message(&self, error: &Error, budget: &Budget, raise: Raise) -> String {
187        if let Error::Budget { breach, .. } = error
188            && let Some(exceeded) = Exceeded::breach(breach, budget, raise)
189        {
190            return exceeded.to_string();
191        }
192        let Error::AliasError { msg, locations } = error else {
193            return error.render_with_formatter(&DefaultMessageFormatter.with_localizer(&Unplaced));
194        };
195        let defined = locations.defined_location;
196        let (text, innermost) = baked_positions(msg);
197        let (line, column) = innermost.unwrap_or((
198            usize::try_from(defined.line()).unwrap_or_default(),
199            usize::try_from(defined.column()).unwrap_or_default(),
200        ));
201        let line = line + self.lines_before;
202        format!("{text} (defined at line {line}, column {column})")
203    }
204
205    /// Validate a parsed value against `context`, and place each of garde's
206    /// problems on the line of the field it names, or of its nearest written
207    /// ancestor.
208    ///
209    /// # Errors
210    ///
211    /// An [`InputError`] with every problem garde reports.
212    pub fn check<T: Validate + ?Sized>(
213        &self,
214        value: &T,
215        context: &T::Context,
216    ) -> Result<(), InputError> {
217        let Err(report) = value.validate_with(context) else {
218            return Ok(());
219        };
220        let document = yamled::Document::parse(self.text.as_str()).ok();
221        let problems = report
222            .iter()
223            .map(|(path, error)| {
224                let field = path.to_string();
225                let location = document
226                    .as_ref()
227                    .and_then(|document| document.locate_nearest(&yaml_path(&field)));
228                Problem {
229                    line: location.map(|at| at.line + self.lines_before),
230                    column: location.map(|at| at.column),
231                    field,
232                    message: error.message().to_owned(),
233                }
234            })
235            .collect();
236        Err(InputError {
237            name: self.name.clone(),
238            problems,
239        })
240    }
241
242    /// Parse and validate with the default context.
243    ///
244    /// # Errors
245    ///
246    /// As [`Input::parse`] and [`Input::check`].
247    pub fn load<T>(&self) -> Result<T, InputError>
248    where
249        T: DeserializeOwned + Validate,
250        T::Context: Default,
251    {
252        let value = self.parse::<T>()?;
253        self.check(&value, &T::Context::default())?;
254        Ok(value)
255    }
256}
257
258/// serde-saphyr's wording without the position it appends. That position
259/// counts from the parsed text, which for frontmatter is not the file, and
260/// [`Problem`] already carries the file's line and column.
261struct Unplaced;
262
263impl Localizer for Unplaced {
264    fn attach_location<'a>(&self, base: Cow<'a, str>, _: Location) -> Cow<'a, str> {
265        base
266    }
267}
268
269/// The text of an [`Error::AliasError`] without the positions serde-saphyr
270/// 1.3.0 wrote into it, and the innermost ` at line` one: where the failing
271/// value is.
272/// That text is rendered in English before any localizer runs, and an alias
273/// reached through another alias nests one rendered error inside the next, so
274/// each layer ends in ` (defined at line N, column M)`, ` at line N, column M`,
275/// or both. Remove this once serde-saphyr keeps the inner error structured.
276fn baked_positions(msg: &str) -> (&str, Option<(usize, usize)>) {
277    let mut text = msg;
278    let mut innermost = None;
279    loop {
280        if let Some((rest, _)) = trailing_position(text, " (defined at line ", ")") {
281            text = rest;
282        } else if let Some((rest, at)) = trailing_position(text, " at line ", "") {
283            text = rest;
284            innermost = Some(at);
285        } else {
286            break;
287        }
288    }
289    (text, innermost)
290}
291
292/// `text` without a last `{lead}N, column M{close}`, with `N` and `M`.
293fn trailing_position<'a>(
294    text: &'a str,
295    lead: &str,
296    close: &str,
297) -> Option<(&'a str, (usize, usize))> {
298    let (rest, position) = text.strip_suffix(close)?.rsplit_once(lead)?;
299    let (line, column) = position.split_once(", column ")?;
300    Some((rest, (line.parse().ok()?, column.parse().ok()?)))
301}
302
303/// A garde field path (`packages[1].name`) as a yamled path. A key holding
304/// `.` or `[` cannot be told apart from nesting, so such a problem lands on
305/// the nearest ancestor that resolves.
306fn yaml_path(field: &str) -> yamled::Path {
307    let mut path = yamled::Path::root();
308    for part in field.split('.') {
309        let mut pieces = part.split('[');
310        if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
311            path = path.key(key);
312        }
313        for index in pieces {
314            let index = index.trim_end_matches(']');
315            path = match index.parse::<usize>() {
316                Ok(index) => path.index(index),
317                Err(_) => path.key(index),
318            };
319        }
320    }
321    path
322}
323
324impl InputError {
325    fn whole(name: String, message: String) -> Self {
326        Self {
327            name,
328            problems: vec![Problem {
329                line: None,
330                column: None,
331                field: String::new(),
332                message,
333            }],
334        }
335    }
336}
337
338/// One problem per line: `name:line:column: field: message`, leaving out
339/// what is not known.
340impl fmt::Display for InputError {
341    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
342        for (index, problem) in self.problems.iter().enumerate() {
343            if index > 0 {
344                writeln!(formatter)?;
345            }
346            write!(formatter, "{}", self.name)?;
347            if let Some(line) = problem.line {
348                write!(formatter, ":{line}")?;
349                if let Some(column) = problem.column {
350                    write!(formatter, ":{column}")?;
351                }
352            }
353            if problem.field.is_empty() {
354                write!(formatter, ": {}", problem.message)?;
355            } else {
356                write!(formatter, ": {}: {}", problem.field, problem.message)?;
357            }
358        }
359        Ok(())
360    }
361}
362
363impl std::error::Error for InputError {}
364
365#[cfg(test)]
366mod tests {
367    use super::baked_positions;
368
369    #[test]
370    fn every_written_position_is_taken_off_the_text() {
371        assert_eq!(
372            baked_positions("invalid u16 (defined at line 2, column 3)"),
373            ("invalid u16", None)
374        );
375        assert_eq!(
376            baked_positions(
377                "invalid u16 at line 2, column 9 (defined at line 2, column 9) at line 3, column 7"
378            ),
379            ("invalid u16", Some((2, 9)))
380        );
381    }
382}