yaml-schema 0.9.1

A YAML schema validator
Documentation
//! Various utility functions

use crate::Result;
use hashlink::linked_hash_map;
use saphyr::{MarkedYaml, Scalar, YamlData};
use std::borrow::Cow;
use std::collections::HashMap;
use std::hash::Hash;

/// Create and return a HashMap with a single key & value
pub fn hash_map<K, V>(key: K, value: V) -> HashMap<K, V>
where
    K: Hash + Eq + Clone,
{
    let mut hash_map = HashMap::with_capacity(1);
    hash_map.insert(key, value);
    hash_map
}

/// Create and return a LinkedHashMap with a single key & value
pub fn linked_hash_map<K, V>(key: K, value: V) -> linked_hash_map::LinkedHashMap<K, V>
where
    K: Hash + Eq + Clone,
{
    let mut linked_hash_map = linked_hash_map::LinkedHashMap::new();
    linked_hash_map.insert(key, value);
    linked_hash_map
}

/// Construct a saphyr::Yaml scalar value from a &str
pub const fn saphyr_yaml_string(s: &str) -> saphyr::Yaml<'_> {
    saphyr::Yaml::Value(saphyr::Scalar::String(Cow::Borrowed(s)))
}

/// Try to unwrap a saphyr::Scalar from a saphyr::Yaml
pub fn try_unwrap_saphyr_scalar<'a>(yaml: &'a saphyr::Yaml) -> Result<&'a saphyr::Scalar<'a>> {
    if let saphyr::Yaml::Value(scalar) = yaml {
        Ok(scalar)
    } else {
        Err(expected_scalar!("Expected a scalar, got: {:?}", yaml))
    }
}

/// Converts a saphyr::Scalar value to a String. Does NOT enclose Scalar::String values in
/// double-quotes.
pub fn scalar_to_string(scalar: &saphyr::Scalar) -> String {
    match scalar {
        saphyr::Scalar::Null => "null".to_string(),
        saphyr::Scalar::Boolean(b) => b.to_string(),
        saphyr::Scalar::Integer(i) => i.to_string(),
        saphyr::Scalar::FloatingPoint(o) => o.to_string(),
        saphyr::Scalar::String(s) => s.to_string(),
    }
}

/// Formats a saphyr::Scalar as a string. Encloses Scalar::String values in double quotes (`"`)
pub fn format_scalar(scalar: &saphyr::Scalar) -> String {
    match scalar {
        saphyr::Scalar::String(s) => format!("\"{s}\""),
        _ => scalar_to_string(scalar),
    }
}

pub fn format_marked_yaml(marked_yaml: &saphyr::MarkedYaml) -> String {
    format!(
        "{} {}",
        format_marker(&marked_yaml.span.start),
        format_yaml_data(&marked_yaml.data)
    )
}

pub fn format_annotated_mapping(
    mapping: &saphyr::AnnotatedMapping<'_, saphyr::MarkedYaml<'_>>,
) -> String {
    let items: Vec<String> = mapping
        .iter()
        .map(|(k, v)| format!("{}: {}", format_yaml_data(&k.data), format_marked_yaml(v)))
        .collect();
    format!("{{ {} }}", items.join(", "))
}

/// Formats a saphyr::YamlData as a string
pub fn format_yaml_data<'a>(data: &saphyr::YamlData<'a, saphyr::MarkedYaml<'a>>) -> String {
    match data {
        saphyr::YamlData::Value(scalar) => format_scalar(scalar),
        saphyr::YamlData::Sequence(seq) => {
            let items: Vec<String> = seq
                .iter()
                .map(|marked_yaml| format_marked_yaml(marked_yaml))
                .collect();
            format!("[{}]", items.join(", "))
        }
        saphyr::YamlData::Mapping(mapping) => format_annotated_mapping(mapping),
        _ => format!("<unsupported type: {data:?}>"),
    }
}

/// Formats a saphyr::Marker as a string. Displays the line and column as a pair of numbers, separated by a comma.
pub fn format_marker(marker: &saphyr::Marker) -> String {
    format!("[{}, {}]", marker.line(), marker.col())
}

/// Formats [`YamlData`] for human-readable type-mismatch messages in validation errors. Scalar
/// kinds get a short type suffix; other shapes use [`Debug`] like the previous `{:?}` output.
///
/// # Examples
///
/// ```
/// use std::borrow::Cow;
/// use ordered_float::OrderedFloat;
/// use saphyr::Scalar;
/// use saphyr::YamlData;
/// use yaml_schema::utils::humanize_yaml_data;
///
/// let data = YamlData::Value(Scalar::Integer(42));
/// assert_eq!(humanize_yaml_data(&data), "42 (int)");
///
/// let data = YamlData::Value(Scalar::FloatingPoint(OrderedFloat::from(3.14)));
/// assert_eq!(humanize_yaml_data(&data), "3.14 (float)");
///
/// let data = YamlData::Value(Scalar::Boolean(true));
/// assert_eq!(humanize_yaml_data(&data), "true (bool)");
///
/// // Strings are JSON-encoded (quoted, with escapes) and suffixed with `(string)`:
///
/// let data = YamlData::Value(Scalar::String(Cow::Borrowed("hello")));
/// assert_eq!(humanize_yaml_data(&data), r#""hello" (string)"#);
///
/// let data = YamlData::Value(Scalar::String(Cow::Borrowed("a\"b")));
/// assert_eq!(humanize_yaml_data(&data), r#""a\"b" (string)"#);
///
/// // `Scalar::Null` and other shapes not given a custom format fall back to [`Debug`]:
///
/// let data = YamlData::Value(Scalar::Null);
/// let s = humanize_yaml_data(&data);
/// assert!(s.contains("Null"), "{s}");
/// ```
pub fn humanize_yaml_data<'input>(data: &YamlData<'input, MarkedYaml<'input>>) -> String {
    match data {
        YamlData::Value(Scalar::String(s)) => format!(
            "{} (string)",
            serde_json::to_string(s.as_ref()).unwrap_or_else(|_| format!("{:?}", s.as_ref()))
        ),
        YamlData::Value(Scalar::Integer(i)) => format!("{i} (int)"),
        YamlData::Value(Scalar::FloatingPoint(f)) => {
            let x = f.into_inner();
            format!(
                "{} (float)",
                serde_json::to_string(&x).unwrap_or_else(|_| format!("{x}"))
            )
        }
        YamlData::Value(Scalar::Boolean(b)) => format!("{b} (bool)"),
        _ => format!("{data:?}"),
    }
}

/// Formats a vector of values as a string, by joining them with commas
pub fn format_vec<V>(vec: &[V]) -> String
where
    V: std::fmt::Display,
{
    let items: Vec<String> = vec.iter().map(|v| format!("{v}")).collect();
    format!("[{}]", items.join(", "))
}

/// Formats a LinkedHashMap as a string, ala JSON
pub fn format_linked_hash_map<K, V>(
    linked_hash_map: &linked_hash_map::LinkedHashMap<K, V>,
) -> String
where
    K: AsRef<str>,
    V: std::fmt::Display,
{
    let items: Vec<String> = linked_hash_map
        .iter()
        .map(|(k, v)| format!("{}: {}", k.as_ref(), v))
        .collect();
    format!("{{ {} }}", items.join(", "))
}

/// Formats a HashMap as a string, ala JSON
pub fn format_hash_map<K, V>(hash_map: &HashMap<K, V>) -> String
where
    K: AsRef<str>,
    V: std::fmt::Display,
{
    if hash_map.is_empty() {
        return "{}".to_string();
    }
    let items: Vec<String> = hash_map
        .iter()
        .map(|(k, v)| format!("\"{}\": {}", k.as_ref(), v))
        .collect();
    format!("{{ {} }}", items.join(", "))
}
/// Collects the keys of a list of SchemaMetadata implementations into a single slice of strings.
pub fn collect_keys(a: &'static [&'static str], b: &'static [&'static str]) -> Vec<&'static str> {
    let mut keys = Vec::with_capacity(a.len() + b.len());
    keys.extend_from_slice(a);
    keys.extend_from_slice(b);
    keys.sort();
    keys.dedup();
    keys
}

/// Filters a saphyr::Mapping and returns a new mapping with only the keys that are in the list.
pub fn filter_mapping<'a>(
    mapping: &saphyr::AnnotatedMapping<'a, saphyr::MarkedYaml<'a>>,
    keys: Vec<&'static str>,
    override_type: &'a str,
) -> Result<saphyr::AnnotatedMapping<'a, saphyr::MarkedYaml<'a>>> {
    let mut filtered_mapping = saphyr::AnnotatedMapping::new();
    for (k, v) in mapping.iter() {
        if let YamlData::Value(Scalar::String(key)) = &k.data {
            if keys.contains(&key.as_ref()) {
                match key.as_ref() {
                    "type" => {
                        filtered_mapping
                            .insert(k.clone(), MarkedYaml::value_from_str(override_type));
                    }
                    _ => {
                        filtered_mapping.insert(k.clone(), v.clone());
                    }
                }
            }
        } else {
            return Err(expected_scalar!("Expected a string key, got: {:?}", k.data));
        }
    }
    Ok(filtered_mapping.into_iter().collect())
}

#[cfg(test)]
mod tests {
    use ordered_float::OrderedFloat;
    use saphyr::LoadableYamlNode as _;
    use std::collections::HashMap;

    use super::*;

    #[test]
    fn test_hash_map() {
        let expected = vec![("foo".to_string(), "bar".to_string())]
            .into_iter()
            .collect::<HashMap<String, String>>();

        let actual = hash_map("foo".to_string(), "bar".to_string());
        assert_eq!(expected, actual);
    }

    #[test]
    #[allow(clippy::approx_constant)]
    fn test_scalar_to_string() {
        assert_eq!("null", scalar_to_string(&saphyr::Scalar::Null));
        assert_eq!("true", scalar_to_string(&saphyr::Scalar::Boolean(true)));
        assert_eq!("false", scalar_to_string(&saphyr::Scalar::Boolean(false)));
        assert_eq!("42", scalar_to_string(&saphyr::Scalar::Integer(42)));
        assert_eq!("-1", scalar_to_string(&saphyr::Scalar::Integer(-1)));
        assert_eq!(
            "3.14",
            scalar_to_string(&saphyr::Scalar::FloatingPoint(OrderedFloat::from(3.14)))
        );
        assert_eq!(
            "foo",
            scalar_to_string(&saphyr::Scalar::String("foo".into()))
        );
    }

    #[test]
    #[allow(clippy::approx_constant)]
    fn test_format_scalar() {
        assert_eq!("null", format_scalar(&saphyr::Scalar::Null));
        assert_eq!("true", format_scalar(&saphyr::Scalar::Boolean(true)));
        assert_eq!("false", format_scalar(&saphyr::Scalar::Boolean(false)));
        assert_eq!("42", format_scalar(&saphyr::Scalar::Integer(42)));
        assert_eq!("-1", format_scalar(&saphyr::Scalar::Integer(-1)));
        assert_eq!(
            "3.14",
            format_scalar(&saphyr::Scalar::FloatingPoint(OrderedFloat::from(3.14)))
        );
        assert_eq!(
            "\"foo\"",
            format_scalar(&saphyr::Scalar::String("foo".into()))
        );
    }

    #[test]
    fn test_format_linked_hash_map() {
        let docs = MarkedYaml::load_from_str("foo: bar").unwrap();
        let doc = docs.first().expect("Expected a document");
        let mapping = doc.data.as_mapping().expect("Expected a mapping");
        let linked_hash_map = mapping
            .into_iter()
            .map(|(k, v)| (format_yaml_data(&k.data), format_yaml_data(&v.data)))
            .collect::<linked_hash_map::LinkedHashMap<String, String>>();
        assert_eq!(
            "{ \"foo\": \"bar\" }",
            format_linked_hash_map(&linked_hash_map)
        );
    }

    #[test]
    fn humanize_yaml_data_integer() {
        let docs = MarkedYaml::load_from_str("42").unwrap();
        assert_eq!(humanize_yaml_data(&docs.first().unwrap().data), "42 (int)");
    }

    #[test]
    fn humanize_yaml_data_float() {
        let docs = MarkedYaml::load_from_str("3.14").unwrap();
        assert_eq!(
            humanize_yaml_data(&docs.first().unwrap().data),
            "3.14 (float)"
        );
    }

    #[test]
    fn humanize_yaml_data_boolean() {
        let docs = MarkedYaml::load_from_str("true").unwrap();
        assert_eq!(
            humanize_yaml_data(&docs.first().unwrap().data),
            "true (bool)"
        );
        let docs = MarkedYaml::load_from_str("false").unwrap();
        assert_eq!(
            humanize_yaml_data(&docs.first().unwrap().data),
            "false (bool)"
        );
    }

    #[test]
    fn humanize_yaml_data_string_plain() {
        let docs = MarkedYaml::load_from_str("hello").unwrap();
        assert_eq!(
            humanize_yaml_data(&docs.first().unwrap().data),
            r#""hello" (string)"#
        );
    }

    #[test]
    fn humanize_yaml_data_string_with_quotes_escaped() {
        let docs = MarkedYaml::load_from_str(r#""str\" ing""#).unwrap();
        assert_eq!(
            humanize_yaml_data(&docs.first().unwrap().data),
            r#""str\" ing" (string)"#
        );
    }

    #[test]
    fn humanize_yaml_data_non_scalar_uses_debug() {
        let docs = MarkedYaml::load_from_str("a: 1").unwrap();
        let s = humanize_yaml_data(&docs.first().unwrap().data);
        assert!(
            s.starts_with("Mapping("),
            "expected Debug fallback for mapping, got {s:?}"
        );
    }
}