zenops-expand 0.5.4

String newtype with `${name}` placeholders that must be expanded before use.
Documentation
use std::fmt;

/// Resolves `${name}` placeholders to their values.
///
/// The lookup side of [`ExpandStr`](crate::ExpandStr). An implementation
/// is handed a name plus a sink and either writes the value or signals
/// the name isn't known. The common implementations are pre-shipped (see
/// the foreign-type impls below); writing a custom one is a single
/// method.
///
/// # Implementing the contract
///
/// `write_value` has a strict contract: on a miss, return
/// [`ExpandLookupError::Unresolved`] and write **nothing** to `f`. Empty
/// writes matter because lookups can be chained — the `[&dyn ExpandLookup; N]`
/// impl walks entries in order, and bytes written by a missing entry
/// would leak into the final expansion before the next entry got a
/// chance to resolve the name.
pub trait ExpandLookup {
    /// Write the value of `name` into `f`, or return
    /// [`ExpandLookupError::Unresolved`] without writing anything.
    ///
    /// See [the trait-level contract](ExpandLookup#implementing-the-contract):
    /// on a miss, no bytes may reach `f`.
    fn write_value<'a>(
        &self,
        name: &'a str,
        f: &mut dyn fmt::Write,
    ) -> Result<(), ExpandLookupError<'a>>;
}

/// Try each lookup in order; return the first hit.
///
/// The fallback pattern: combine a user-supplied lookup with a default
/// lookup (or a chain of them) without copying values around. Search
/// stops at the first entry that resolves the name.
///
/// # Example
///
/// ```
/// use std::collections::HashMap;
/// use zenops_expand::{ExpandLookup, ExpandStr};
///
/// let mut overrides = HashMap::new();
/// overrides.insert("name", "Ada");
///
/// let mut defaults = HashMap::new();
/// defaults.insert("name", "anon");
/// defaults.insert("greeting", "hello");
///
/// let chain: [&dyn ExpandLookup; 2] = [&overrides, &defaults];
/// let t = ExpandStr::new_static("${greeting}, ${name}!");
///
/// assert_eq!(t.expand_to_string(&chain).unwrap(), "hello, Ada!");
/// ```
impl<const SIZE: usize, T: ExpandLookup + ?Sized> ExpandLookup for [&T; SIZE] {
    fn write_value<'a>(
        &self,
        name: &'a str,
        f: &mut dyn fmt::Write,
    ) -> Result<(), ExpandLookupError<'a>> {
        for expander in self {
            match expander.write_value(name, f) {
                Ok(()) => return Ok(()),
                Err(ExpandLookupError::Unresolved(_)) => continue,
                Err(ExpandLookupError::WriteFmt(e)) => return Err(ExpandLookupError::WriteFmt(e)),
            }
        }
        Err(ExpandLookupError::Unresolved(name))
    }
}

/// Looks `name` up as a string-borrowable key. Available with the
/// `indexmap` feature.
#[cfg(feature = "indexmap")]
impl<K, V> ExpandLookup for indexmap::IndexMap<K, V>
where
    K: std::borrow::Borrow<str>,
    V: AsRef<str>,
{
    fn write_value<'a>(
        &self,
        name: &'a str,
        f: &mut dyn fmt::Write,
    ) -> Result<(), ExpandLookupError<'a>> {
        if let Some(value) = self.get(name) {
            f.write_str(value.as_ref())?;
            Ok(())
        } else {
            Err(ExpandLookupError::Unresolved(name))
        }
    }
}

/// Looks `name` up as a string-borrowable key.
impl<K, V> ExpandLookup for std::collections::BTreeMap<K, V>
where
    K: Ord + std::borrow::Borrow<str>,
    V: AsRef<str>,
{
    fn write_value<'a>(
        &self,
        name: &'a str,
        f: &mut dyn fmt::Write,
    ) -> Result<(), ExpandLookupError<'a>> {
        if let Some(value) = self.get(name) {
            f.write_str(value.as_ref())?;
            Ok(())
        } else {
            Err(ExpandLookupError::Unresolved(name))
        }
    }
}

/// Looks `name` up as a string-borrowable key.
impl<K, V> ExpandLookup for std::collections::HashMap<K, V>
where
    K: Eq + std::hash::Hash + std::borrow::Borrow<str>,
    V: AsRef<str>,
{
    fn write_value<'a>(
        &self,
        name: &'a str,
        f: &mut dyn fmt::Write,
    ) -> Result<(), ExpandLookupError<'a>> {
        if let Some(value) = self.get(name) {
            f.write_str(value.as_ref())?;
            Ok(())
        } else {
            Err(ExpandLookupError::Unresolved(name))
        }
    }
}

/// Error returned from [`ExpandLookup::write_value`].
///
/// The `'a` lifetime is borrowed from the unresolved-name slice passed
/// into the call. Unresolved misses are common inside chained lookups,
/// so the error avoids allocating; the converting `From` impl into
/// [`ExpandError`](crate::ExpandError) is where the name finally gets
/// copied if the miss escapes.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ExpandLookupError<'a> {
    /// `name` is not known to this lookup.
    #[error("Failed to resolve `${{{0}}}`")]
    Unresolved(&'a str),
    /// The [`fmt::Write`] sink returned an error.
    #[error(transparent)]
    WriteFmt(#[from] fmt::Error),
}

impl<'a> From<ExpandLookupError<'a>> for crate::ExpandError {
    fn from(value: ExpandLookupError<'a>) -> Self {
        match value {
            ExpandLookupError::Unresolved(name) => crate::ExpandError::Unresolved(name.into()),
            ExpandLookupError::WriteFmt(e) => crate::ExpandError::WriteFmt(e),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write_to_string<L: ExpandLookup>(lookup: &L, key: &str) -> Result<String, String> {
        let mut buf = String::new();
        lookup
            .write_value(key, &mut buf)
            .map(|()| buf)
            .map_err(|e| e.to_string())
    }

    #[test]
    fn btreemap_writes_value_for_known_key() {
        let mut m = std::collections::BTreeMap::new();
        m.insert("name", "alice");
        assert_eq!(write_to_string(&m, "name").unwrap(), "alice");
    }

    #[test]
    fn btreemap_returns_unresolved_for_missing_key() {
        let m: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
        let mut buf = String::new();
        let err = m.write_value("missing", &mut buf).unwrap_err();
        assert_eq!(err, ExpandLookupError::Unresolved("missing"));
        assert!(buf.is_empty(), "lookup must not write on miss");
    }

    #[test]
    fn hashmap_writes_value_for_known_key() {
        let mut m = std::collections::HashMap::new();
        m.insert("name", "alice");
        assert_eq!(write_to_string(&m, "name").unwrap(), "alice");
    }

    #[test]
    fn hashmap_returns_unresolved_for_missing_key() {
        let m: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
        let mut buf = String::new();
        let err = m.write_value("missing", &mut buf).unwrap_err();
        assert_eq!(err, ExpandLookupError::Unresolved("missing"));
        assert!(buf.is_empty());
    }

    #[cfg(feature = "indexmap")]
    #[test]
    fn indexmap_writes_value_for_known_key() {
        let mut m = indexmap::IndexMap::new();
        m.insert("name", "alice");
        assert_eq!(write_to_string(&m, "name").unwrap(), "alice");
    }

    #[cfg(feature = "indexmap")]
    #[test]
    fn indexmap_returns_unresolved_for_missing_key() {
        let m: indexmap::IndexMap<&str, &str> = indexmap::IndexMap::new();
        let mut buf = String::new();
        let err = m.write_value("missing", &mut buf).unwrap_err();
        assert_eq!(err, ExpandLookupError::Unresolved("missing"));
        assert!(buf.is_empty());
    }

    #[test]
    fn array_chain_returns_first_hit() {
        let mut a = std::collections::BTreeMap::new();
        a.insert("k", "from_a");
        let mut b = std::collections::BTreeMap::new();
        b.insert("k", "from_b");

        let chain: [&dyn ExpandLookup; 2] = [&a, &b];
        let mut buf = String::new();
        chain.write_value("k", &mut buf).unwrap();
        assert_eq!(buf, "from_a");
    }

    #[test]
    fn array_chain_falls_through_to_second_lookup() {
        let a: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
        let mut b = std::collections::BTreeMap::new();
        b.insert("k", "from_b");

        let chain: [&dyn ExpandLookup; 2] = [&a, &b];
        let mut buf = String::new();
        chain.write_value("k", &mut buf).unwrap();
        assert_eq!(buf, "from_b");
    }

    #[test]
    fn array_chain_returns_unresolved_when_no_lookup_has_key() {
        let a: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
        let b: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();

        let chain: [&dyn ExpandLookup; 2] = [&a, &b];
        let mut buf = String::new();
        let err = chain.write_value("missing", &mut buf).unwrap_err();
        assert_eq!(err, ExpandLookupError::Unresolved("missing"));
    }
}