// SPDX-License-Identifier: Prosperity-3.0.0 // Copyright Scientific Computing Studio // Source: https://git.scient.ing/education/coursebank //! Reading and writing the YAML and JSON files the tool owns. //! //! Two small conveniences live here. First, every read and write attaches the //! path to its error, because "invalid type: found string" is useless without //! knowing which of forty bank files produced it. Second, [`flexible_string`] //! lets `schema_version: 1.0` parse as the string `"1.0"`. YAML reads an //! unquoted `1.0` as a float, and being told to go back and add quotation marks //! is a poor first experience of a schema. use std::fmt; use std::fs; use std::path::Path; use serde::de::{self, DeserializeOwned, Visitor}; use serde::{Deserialize, Deserializer, Serialize}; use crate::error::{Error, Result}; /// Deserializes a YAML file into any schema type. /// /// # Arguments /// /// * `path` - the file to read. /// /// # Returns /// /// The deserialized value. /// /// # Errors /// /// Returns [`Error::Io`] when the file cannot be read and [`Error::Yaml`] when /// it does not match the target schema. pub fn read(path: &Path) -> Result { let text = fs::read_to_string(path).map_err(|e| Error::io(path, e))?; serde_yaml_ng::from_str(&text).map_err(|source| Error::Yaml { path: path.to_path_buf(), source, }) } /// Serializes a value to a YAML file, creating parent directories as needed. /// /// # Arguments /// /// * `path` - the destination file. /// * `value` - the value to write. /// /// # Errors /// /// Returns [`Error::Io`] on a write failure, or [`Error::Other`] if the value /// cannot be represented as YAML. pub fn write(path: &Path, value: &T) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?; } let text = serde_yaml_ng::to_string(value).map_err(Error::other)?; fs::write(path, text).map_err(|e| Error::io(path, e)) } /// Serializes a value to a YAML string. /// /// Used where the caller needs to put something in front of the document, such /// as the banner on a generated file. /// /// # Arguments /// /// * `value` - the value to serialize. /// /// # Returns /// /// The YAML text. /// /// # Errors /// /// Returns [`Error::Other`] if the value cannot be represented as YAML. pub fn to_string(value: &T) -> Result { serde_yaml_ng::to_string(value).map_err(Error::other) } /// Deserializes a JSON file into any type. /// /// Used only for importing legacy banks and for reading emitted schemas back in /// tests; the tool's own files are YAML. /// /// # Arguments /// /// * `path` - the file to read. /// /// # Returns /// /// The deserialized value. /// /// # Errors /// /// Returns [`Error::Io`] or [`Error::Json`]. pub fn read_json(path: &Path) -> Result { let text = fs::read_to_string(path).map_err(|e| Error::io(path, e))?; serde_json::from_str(&text).map_err(|source| Error::Json { path: path.to_path_buf(), source, }) } /// Writes a value as pretty-printed JSON. /// /// # Arguments /// /// * `path` - the destination file. /// * `value` - the value to write. /// /// # Errors /// /// Returns [`Error::Io`] on a write failure. pub fn write_json(path: &Path, value: &T) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?; } let text = serde_json::to_string_pretty(value).map_err(Error::other)?; fs::write(path, format!("{text}\n")).map_err(|e| Error::io(path, e)) } /// Writes text to a file, creating parent directories as needed. /// /// # Arguments /// /// * `path` - the destination file. /// * `text` - the contents. /// /// # Errors /// /// Returns [`Error::Io`] on a write failure. pub fn write_text(path: &Path, text: &str) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?; } fs::write(path, text).map_err(|e| Error::io(path, e)) } /// Lists the `*.yaml` and `*.yml` files in a directory, sorted by name. /// /// Sorting makes every downstream output deterministic, which matters when the /// outputs are committed. /// /// # Arguments /// /// * `dir` - the directory to scan. /// /// # Returns /// /// The paths, empty when the directory does not exist. /// /// # Errors /// /// Returns [`Error::Io`] when the directory exists but cannot be read. pub fn list_yaml(dir: &Path) -> Result> { if !dir.exists() { return Ok(Vec::new()); } let mut out = Vec::new(); for entry in fs::read_dir(dir).map_err(|e| Error::io(dir, e))? { let entry = entry.map_err(|e| Error::io(dir, e))?; let path = entry.path(); let is_yaml = path .extension() .and_then(|e| e.to_str()) .map(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml")) .unwrap_or(false); if is_yaml && path.is_file() { out.push(path); } } out.sort(); Ok(out) } /// Deserializes a scalar as a string, whether it was written quoted or not. /// /// # Arguments /// /// * `d` - the deserializer. /// /// # Returns /// /// The value as a string. /// /// # Errors /// /// Returns a deserialization error for non-scalar input. pub fn flexible_string<'de, D>(d: D) -> std::result::Result where D: Deserializer<'de>, { struct V; impl<'a> Visitor<'a> for V { type Value = String; fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("a version such as \"1.0\"") } fn visit_str(self, v: &str) -> std::result::Result { Ok(v.to_string()) } fn visit_f64(self, v: f64) -> std::result::Result { // 1.0 must render as "1.0", not "1". Ok(format!("{v:.1}")) } fn visit_u64(self, v: u64) -> std::result::Result { Ok(format!("{v}.0")) } fn visit_i64(self, v: i64) -> std::result::Result { Ok(format!("{v}.0")) } } d.deserialize_any(V) } /// Deserializes an optional scalar as a string, quoted or not. /// /// The [`flexible_string`] of a field that may be absent, which is what a /// fragment's `schema_version` is: one file in a course declares it and the /// rest inherit. /// /// # Arguments /// /// * `d` - the deserializer. /// /// # Returns /// /// The value as a string, or `None`. /// /// # Errors /// /// Returns a deserialization error for non-scalar input. pub fn flexible_string_opt<'de, D>(d: D) -> std::result::Result, D::Error> where D: Deserializer<'de>, { #[derive(serde::Deserialize)] struct Wrapper(#[serde(deserialize_with = "flexible_string")] String); Ok(Option::::deserialize(d)?.map(|w| w.0)) } #[cfg(test)] mod tests { use super::*; use serde::Deserialize; #[derive(Deserialize)] struct Versioned { #[serde(deserialize_with = "flexible_string")] v: String, } #[test] fn flexible_string_accepts_quoted_and_bare_versions() { for (src, want) in [ ("v: \"1.0\"", "1.0"), ("v: 1.0", "1.0"), ("v: 2", "2.0"), ("v: \"1.10\"", "1.10"), ] { let got: Versioned = serde_yaml_ng::from_str(src).expect(src); assert_eq!(got.v, want, "{src}"); } } }