1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33#[non_exhaustive]
34pub enum Format {
35 Yaml,
37 #[cfg(feature = "input-toml")]
39 Toml,
40}
41
42impl Format {
43 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#[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#[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#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct InputError {
97 pub name: String,
99 pub problems: Vec<Problem>,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq)]
105pub struct Problem {
106 pub line: Option<usize>,
108 pub column: Option<usize>,
110 pub field: String,
113 pub message: String,
115}
116
117impl Input {
118 #[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 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 pub fn open(path: &Path) -> Result<Self, InputError> {
152 let format = Format::of(path)?;
153 Ok(Self::read(path)?.format(format))
154 }
155
156 #[must_use]
158 pub fn format(mut self, format: Format) -> Self {
159 self.format = format;
160 self
161 }
162
163 #[must_use]
165 pub fn name(&self) -> &str {
166 &self.name
167 }
168
169 #[must_use]
171 pub fn text(&self) -> &str {
172 &self.text
173 }
174
175 #[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 #[must_use]
206 pub fn limits(mut self, limits: Limits) -> Self {
207 self.limits = limits;
208 self
209 }
210
211 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 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 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 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 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 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 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
396struct 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
411enum Step<'field> {
413 Key(&'field str),
415 Index(usize),
417}
418
419fn 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
440fn 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
464impl 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 {}