zenops-expand 0.5.4

String newtype with `${name}` placeholders that must be expanded before use.
Documentation
#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs)]

//! String templates with `${name}` placeholders that have to be resolved
//! before use.
//!
//! [`ExpandStr`] wraps a template string and refuses to be implicitly
//! coerced to a `&str`: it does not implement [`Display`], `AsRef<str>`,
//! or `Deref<Target = str>`. Callers go through
//! [`expand_to_string`](ExpandStr::expand_to_string) or
//! [`write_expanded`](ExpandStr::write_expanded) with an [`ExpandLookup`],
//! and the missing trait impls mean the compiler complains the moment a
//! raw template leaks into a `println!`, a path, or a shell command.
//!
//! Placeholders are exactly `${name}` — no other syntax, no escape
//! sequence. An unresolved placeholder is an error; so is an unterminated
//! `${`. The lookup side is pluggable: implement [`ExpandLookup`] yourself,
//! reach for the [`HashMap`] / [`BTreeMap`] / `IndexMap` impls that ship
//! with the crate, or chain several with `[&dyn ExpandLookup; N]` for an
//! ordered fallback search.
//!
//! # Example
//!
//! ```
//! use std::collections::HashMap;
//! use zenops_expand::ExpandStr;
//!
//! let t = ExpandStr::new_static("hello, ${name}!");
//!
//! let mut lookup = HashMap::new();
//! lookup.insert("name", "world");
//!
//! assert_eq!(t.expand_to_string(&lookup).unwrap(), "hello, world!");
//! ```
//!
//! # Features
//!
//! - `indexmap` — [`ExpandLookup`] impl for [`indexmap::IndexMap`].
//!
//! [`Display`]: std::fmt::Display
//! [`HashMap`]: std::collections::HashMap
//! [`BTreeMap`]: std::collections::BTreeMap

mod expand_lookup;

use std::fmt;

use serde::Deserialize;
use smol_str::SmolStr;

pub use expand_lookup::{ExpandLookup, ExpandLookupError};

/// Error returned from expanding an [`ExpandStr`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ExpandError {
    /// A `${name}` placeholder was not resolved by the lookup.
    #[error("unresolved placeholder `${{{0}}}`")]
    Unresolved(SmolStr),
    /// The [`fmt::Write`] sink returned an error.
    #[error(transparent)]
    WriteFmt(#[from] fmt::Error),
    /// A `${` sequence was never closed by `}`.
    #[error("unterminated `${{` in template")]
    Unterminated,
}

/// A template string with `${name}` placeholders, awaiting expansion.
///
/// Construct with [`new`](Self::new) (or [`new_static`](Self::new_static)
/// for a `'static` literal), then resolve against an [`ExpandLookup`]
/// with [`expand_to_string`](Self::expand_to_string) or
/// [`write_expanded`](Self::write_expanded). Deserialises transparently
/// from a string, so an `ExpandStr` field in a serde config is just a
/// plain string in TOML / JSON / YAML.
///
/// Construction performs no validation: a template with an unterminated
/// `${` or an unresolved placeholder is held verbatim until expansion
/// time and only then errors. The trade-off is that `new` / `new_static`
/// are infallible — and [`new_static`](Self::new_static) is `const`, so an
/// `ExpandStr` can live in a `const` or `static` binding.
///
/// # Example
///
/// ```
/// use std::collections::HashMap;
/// use zenops_expand::ExpandStr;
///
/// let path = ExpandStr::new_static("${home}/.config");
///
/// let mut env = HashMap::new();
/// env.insert("home", "/home/ada");
///
/// assert_eq!(path.expand_to_string(&env).unwrap(), "/home/ada/.config");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct ExpandStr(SmolStr);

#[cfg(feature = "schemars")]
impl schemars::JsonSchema for ExpandStr {
    fn schema_name() -> std::borrow::Cow<'static, str> {
        "ExpandStr".into()
    }

    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
        schemars::json_schema!({
            "type": "string",
            "description": "Template string containing `${name}` placeholders, expanded at apply time.",
        })
    }
}

impl ExpandStr {
    /// Wrap a template string.
    pub fn new(raw: SmolStr) -> Self {
        Self(raw)
    }

    /// Wrap a `'static` template string without allocating.
    ///
    /// `const`, so suitable for `const` and `static` bindings.
    pub const fn new_static(raw: &'static str) -> Self {
        Self(SmolStr::new_static(raw))
    }

    /// Expand the template into a new [`String`].
    ///
    /// Each `${name}` is replaced with the value `lookup` writes for that
    /// name. Literal characters pass through unchanged.
    ///
    /// # Example
    ///
    /// ```
    /// use std::collections::HashMap;
    /// use zenops_expand::ExpandStr;
    ///
    /// let t = ExpandStr::new_static("${greeting}, ${name}!");
    ///
    /// let mut lookup: HashMap<&str, &str> = HashMap::new();
    /// lookup.insert("greeting", "hi");
    /// lookup.insert("name", "Ada");
    ///
    /// assert_eq!(t.expand_to_string(&lookup).unwrap(), "hi, Ada!");
    /// ```
    pub fn expand_to_string(
        &self,
        lookup: &(impl ExpandLookup + ?Sized),
    ) -> Result<String, ExpandError> {
        let mut out = String::with_capacity(self.0.len() * 2);
        self.write_expanded(lookup, &mut out)?;
        Ok(out)
    }

    /// Expand the template into an existing [`fmt::Write`] sink.
    ///
    /// Equivalent to [`expand_to_string`] but writes into a caller-supplied
    /// buffer, so multiple templates can be concatenated without
    /// intermediate allocations. On error the sink may have been written
    /// to partially.
    ///
    /// # Example
    ///
    /// ```
    /// use std::collections::HashMap;
    /// use std::fmt::Write;
    /// use zenops_expand::ExpandStr;
    ///
    /// let mut lookup: HashMap<&str, &str> = HashMap::new();
    /// lookup.insert("user", "ada");
    ///
    /// let mut out = String::from("path=");
    /// let t = ExpandStr::new_static("/home/${user}");
    /// t.write_expanded(&lookup, &mut out).unwrap();
    /// write!(out, ";").unwrap();
    ///
    /// assert_eq!(out, "path=/home/ada;");
    /// ```
    ///
    /// [`expand_to_string`]: ExpandStr::expand_to_string
    pub fn write_expanded(
        &self,
        lookup: &(impl ExpandLookup + ?Sized),
        f: &mut impl fmt::Write,
    ) -> Result<(), ExpandError> {
        let mut rest = self.0.as_str();
        while let Some(start) = rest.find("${") {
            f.write_str(&rest[..start])?;
            let after_open = &rest[start + 2..];
            let end = after_open.find('}').ok_or(ExpandError::Unterminated)?;
            let name = &after_open[..end];
            lookup.write_value(name, f)?;
            rest = &after_open[end + 1..];
        }
        f.write_str(rest)?;
        Ok(())
    }

    /// Get the raw template string.
    pub fn as_template(&self) -> &str {
        &self.0
    }
}

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

    fn make_lookup(pairs: &[(&str, &str)]) -> HashMap<String, SmolStr> {
        pairs
            .iter()
            .map(|(k, v)| ((*k).to_string(), SmolStr::new(*v)))
            .collect()
    }

    #[test]
    fn literal_passthrough() {
        let s = ExpandStr::new_static("plain text");
        assert_eq!(s.expand_to_string(&make_lookup(&[])).unwrap(), "plain text");
    }

    #[test]
    fn resolves_single() {
        let s = ExpandStr::new_static("hello ${name}!");
        let m = make_lookup(&[("name", "world")]);
        assert_eq!(s.expand_to_string(&m).unwrap(), "hello world!");
    }

    #[test]
    fn resolves_adjacent_and_repeated() {
        let s = ExpandStr::new_static("${a}${b}-${a}");
        let m = make_lookup(&[("a", "X"), ("b", "Y")]);
        assert_eq!(s.expand_to_string(&m).unwrap(), "XY-X");
    }

    #[test]
    fn resolves_at_boundaries() {
        let s = ExpandStr::new_static("${a}");
        let m = make_lookup(&[("a", "A")]);
        assert_eq!(s.expand_to_string(&m).unwrap(), "A");
    }

    #[test]
    fn unresolved_key_errors() {
        let s = ExpandStr::new_static("a ${missing} b");
        let m = make_lookup(&[]);
        assert_eq!(
            s.expand_to_string(&m),
            Err(ExpandError::Unresolved(SmolStr::new_static("missing"))),
        );
    }

    #[test]
    fn unterminated_errors() {
        let s = ExpandStr::new_static("a ${oops");
        let m = make_lookup(&[]);
        assert_eq!(s.expand_to_string(&m), Err(ExpandError::Unterminated));
    }

    #[test]
    fn deserializes_from_toml_string() {
        #[derive(Deserialize)]
        struct Holder {
            v: ExpandStr,
        }
        let h: Holder = toml::from_str(r#"v = "x-${y}-z""#).unwrap();
        assert_eq!(h.v.as_template(), "x-${y}-z");
    }

    #[test]
    fn dyn_compatible() {
        let a = make_lookup(&[("a", "A")]);
        let b = make_lookup(&[("b", "B")]);

        // &dyn ExpandLookup accepted directly.
        let dyn_lookup: &dyn ExpandLookup = &a;
        let s = ExpandStr::new_static("${a}");
        assert_eq!(s.expand_to_string(dyn_lookup).unwrap(), "A");

        // Heterogeneous chain via [&dyn ExpandLookup; N].
        let chain: [&dyn ExpandLookup; 2] = [&a, &b];
        let s = ExpandStr::new_static("${a}/${b}");
        assert_eq!(s.expand_to_string(&chain).unwrap(), "A/B");
    }

    #[test]
    fn array_lookup_falls_through_and_propagates_unresolved() {
        let primary = make_lookup(&[("a", "from-primary")]);
        let fallback = make_lookup(&[("b", "from-fallback")]);
        let chain = [&primary, &fallback];

        let s = ExpandStr::new_static("${a}/${b}");
        assert_eq!(
            s.expand_to_string(&chain).unwrap(),
            "from-primary/from-fallback"
        );

        // If nothing in the chain resolves, the final result must be
        // Unresolved — not a silent empty expansion.
        let s = ExpandStr::new_static("${missing}");
        assert_eq!(
            s.expand_to_string(&chain),
            Err(ExpandError::Unresolved(SmolStr::new_static("missing"))),
        );
    }
}