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::fmt;
8use std::path::Path;
9
10use garde::Validate;
11use serde::de::DeserializeOwned;
12use serde_saphyr::{DuplicateKeyPolicy, MergeKeyPolicy};
13
14/// A named YAML text, ready to parse and validate.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct Input {
17    name: String,
18    text: String,
19    lines_before: usize,
20}
21
22/// Why an input was refused: every problem found, each placed on a line when
23/// the text allows it.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct InputError {
26    /// The input's name, usually its path.
27    pub name: String,
28    /// What is wrong, in the order found.
29    pub problems: Vec<Problem>,
30}
31
32/// One thing wrong with an input.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct Problem {
35    /// 1-based line, when known.
36    pub line: Option<usize>,
37    /// 1-based column, when known.
38    pub column: Option<usize>,
39    /// The field it concerns, as garde names it (`packages[1].name`); empty
40    /// for a problem with the whole text.
41    pub field: String,
42    /// What a person changes to fix it.
43    pub message: String,
44}
45
46impl Input {
47    /// An input from text already in memory.
48    #[must_use]
49    pub fn new(name: impl Into<String>, text: impl Into<String>) -> Self {
50        Self {
51            name: name.into(),
52            text: text.into(),
53            lines_before: 0,
54        }
55    }
56
57    /// Read a file, named by its path.
58    ///
59    /// # Errors
60    ///
61    /// An [`InputError`] with the I/O failure.
62    pub fn read(path: &Path) -> Result<Self, InputError> {
63        let name = path.display().to_string();
64        match std::fs::read_to_string(path) {
65            Ok(text) => Ok(Self::new(name, text)),
66            Err(error) => Err(InputError::whole(name, error.to_string())),
67        }
68    }
69
70    /// The input's name.
71    #[must_use]
72    pub fn name(&self) -> &str {
73        &self.name
74    }
75
76    /// The input's text.
77    #[must_use]
78    pub fn text(&self) -> &str {
79        &self.text
80    }
81
82    /// Split YAML frontmatter from a Markdown body: the text between a first
83    /// line of `---` and the next line of `---`. Problems in the frontmatter
84    /// keep the line numbers of the whole file. `None` when the text does not
85    /// start with a `---` line, or never closes it.
86    #[must_use]
87    pub fn frontmatter(&self) -> Option<(Self, &str)> {
88        let text = self.text.strip_prefix('\u{feff}').unwrap_or(&self.text);
89        let rest = text
90            .strip_prefix("---\n")
91            .or_else(|| text.strip_prefix("---\r\n"))?;
92        let mut offset = 0;
93        for line in rest.split_inclusive('\n') {
94            if line.trim_end() == "---" {
95                let matter = Self {
96                    name: self.name.clone(),
97                    text: rest[..offset].to_owned(),
98                    lines_before: self.lines_before + 1,
99                };
100                return Some((matter, &rest[offset + line.len()..]));
101            }
102            offset += line.len();
103        }
104        None
105    }
106
107    /// Parse the text's shape. Only `true` and `false` are booleans, and a
108    /// duplicate key or a merge key (`<<`) is refused.
109    ///
110    /// # Errors
111    ///
112    /// An [`InputError`] with the parser's problem and its line.
113    pub fn parse<T: DeserializeOwned>(&self) -> Result<T, InputError> {
114        let options = serde_saphyr::options! {
115            strict_booleans: true,
116            duplicate_keys: DuplicateKeyPolicy::Error,
117            merge_keys: MergeKeyPolicy::Error,
118            with_snippet: false,
119        };
120        serde_saphyr::from_str_with_options(&self.text, options).map_err(|error| {
121            let location = error.location();
122            InputError {
123                name: self.name.clone(),
124                problems: vec![Problem {
125                    line: location
126                        .and_then(|at| usize::try_from(at.line()).ok())
127                        .map(|line| line + self.lines_before),
128                    column: location.and_then(|at| usize::try_from(at.column()).ok()),
129                    field: String::new(),
130                    message: error.without_snippet().to_string(),
131                }],
132            }
133        })
134    }
135
136    /// Validate a parsed value against `context`, and place each of garde's
137    /// problems on the line of the field it names, or of its nearest written
138    /// ancestor.
139    ///
140    /// # Errors
141    ///
142    /// An [`InputError`] with every problem garde reports.
143    pub fn check<T: Validate + ?Sized>(
144        &self,
145        value: &T,
146        context: &T::Context,
147    ) -> Result<(), InputError> {
148        let Err(report) = value.validate_with(context) else {
149            return Ok(());
150        };
151        let document = yamled::Document::parse(self.text.as_str()).ok();
152        let problems = report
153            .iter()
154            .map(|(path, error)| {
155                let field = path.to_string();
156                let location = document
157                    .as_ref()
158                    .and_then(|document| document.locate_nearest(&yaml_path(&field)));
159                Problem {
160                    line: location.map(|at| at.line + self.lines_before),
161                    column: location.map(|at| at.column),
162                    field,
163                    message: error.message().to_owned(),
164                }
165            })
166            .collect();
167        Err(InputError {
168            name: self.name.clone(),
169            problems,
170        })
171    }
172
173    /// Parse and validate with the default context.
174    ///
175    /// # Errors
176    ///
177    /// As [`Input::parse`] and [`Input::check`].
178    pub fn load<T>(&self) -> Result<T, InputError>
179    where
180        T: DeserializeOwned + Validate,
181        T::Context: Default,
182    {
183        let value = self.parse::<T>()?;
184        self.check(&value, &T::Context::default())?;
185        Ok(value)
186    }
187}
188
189/// A garde field path (`packages[1].name`) as a yamled path. A key holding
190/// `.` or `[` cannot be told apart from nesting, so such a problem lands on
191/// the nearest ancestor that resolves.
192fn yaml_path(field: &str) -> yamled::Path {
193    let mut path = yamled::Path::root();
194    for part in field.split('.') {
195        let mut pieces = part.split('[');
196        if let Some(key) = pieces.next().filter(|key| !key.is_empty()) {
197            path = path.key(key);
198        }
199        for index in pieces {
200            let index = index.trim_end_matches(']');
201            path = match index.parse::<usize>() {
202                Ok(index) => path.index(index),
203                Err(_) => path.key(index),
204            };
205        }
206    }
207    path
208}
209
210impl InputError {
211    fn whole(name: String, message: String) -> Self {
212        Self {
213            name,
214            problems: vec![Problem {
215                line: None,
216                column: None,
217                field: String::new(),
218                message,
219            }],
220        }
221    }
222}
223
224/// One problem per line: `name:line:column: field: message`, leaving out
225/// what is not known.
226impl fmt::Display for InputError {
227    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
228        for (index, problem) in self.problems.iter().enumerate() {
229            if index > 0 {
230                writeln!(formatter)?;
231            }
232            write!(formatter, "{}", self.name)?;
233            if let Some(line) = problem.line {
234                write!(formatter, ":{line}")?;
235                if let Some(column) = problem.column {
236                    write!(formatter, ":{column}")?;
237                }
238            }
239            if problem.field.is_empty() {
240                write!(formatter, ": {}", problem.message)?;
241            } else {
242                write!(formatter, ": {}: {}", problem.field, problem.message)?;
243            }
244        }
245        Ok(())
246    }
247}
248
249impl std::error::Error for InputError {}