276 lines
7.5 KiB
Rust
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}");
|
|
}
|
|
}
|
|
}
|