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