@@ -0,0 +1,228 @@
|
||||
// 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::{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))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user