Files
coursebank/src/util/yaml.rs
T
2026-09-26 01:15:12 -04:00

276 lines
7.5 KiB
Rust

// 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<T: DeserializeOwned>(path: &Path) -> Result<T> {
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<T: Serialize>(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<T: Serialize>(value: &T) -> Result<String> {
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<T: DeserializeOwned>(path: &Path) -> Result<T> {
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<T: Serialize>(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<Vec<std::path::PathBuf>> {
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<String, D::Error>
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<E: de::Error>(self, v: &str) -> std::result::Result<String, E> {
Ok(v.to_string())
}
fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<String, E> {
// 1.0 must render as "1.0", not "1".
Ok(format!("{v:.1}"))
}
fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<String, E> {
Ok(format!("{v}.0"))
}
fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<String, E> {
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<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct Wrapper(#[serde(deserialize_with = "flexible_string")] String);
Ok(Option::<Wrapper>::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}");
}
}
}