behavior-contracts 0.3.0

Language-neutral IR runtime core (expression evaluation, template rendering, execution plan, canonical serialization) shared across DSL implementations. Passes the dsl-contracts conformance vectors byte-for-byte.
Documentation
//! template — template-rendering.md reference implementation.
//!
//! render_template(template, params): replace `{param}` placeholders with bound
//! values (strict). `{result.*}` / `{item.*}` are resolved by a different layer
//! and are out-of-scope Failures here.
//!
//! Normative points:
//!   - placeholder = `{` + one-or-more non-brace chars + `}` (no escaping).
//!   - single left-to-right pass; substituted text is not re-processed.
//!   - strict missing-key: unbound param → Failure (never empty string).
//!   - null param → Failure (present = key in params && value != null).
//!   - stringify follows Python str(value): bool → True/False, float → py_float_repr.

use crate::canonical::py_float_repr;
use crate::value::Value;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateFailureCode {
    UnboundParam,
    ResultScopeInCore,
    InvalidStringify,
}

impl TemplateFailureCode {
    pub fn as_str(self) -> &'static str {
        match self {
            TemplateFailureCode::UnboundParam => "UNBOUND_PARAM",
            TemplateFailureCode::ResultScopeInCore => "RESULT_SCOPE_IN_CORE",
            TemplateFailureCode::InvalidStringify => "INVALID_STRINGIFY",
        }
    }
}

#[derive(Debug, Clone)]
pub struct TemplateFailure {
    pub code: TemplateFailureCode,
    pub message: String,
}

impl std::fmt::Display for TemplateFailure {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.code.as_str(), self.message)
    }
}
impl std::error::Error for TemplateFailure {}

fn fail<T>(code: TemplateFailureCode, message: impl Into<String>) -> Result<T, TemplateFailure> {
    Err(TemplateFailure {
        code,
        message: message.into(),
    })
}

/// Render `{param}` placeholders strictly.
pub fn render_template(
    template: &str,
    params: &[(String, Value)],
) -> Result<String, TemplateFailure> {
    let mut out = String::with_capacity(template.len());
    let chars: Vec<char> = template.chars().collect();
    let mut i = 0;
    while i < chars.len() {
        let c = chars[i];
        if c == '{' {
            // find matching `}` with no brace inside and non-empty name.
            if let Some(close) = find_placeholder_close(&chars, i) {
                let name: String = chars[i + 1..close].iter().collect();
                // name is guaranteed non-empty and brace-free by find_placeholder_close
                out.push_str(&resolve(template, &name, params)?);
                i = close + 1;
                continue;
            }
            // not a placeholder ({} or unmatched): emit literally.
            out.push(c);
            i += 1;
        } else {
            out.push(c);
            i += 1;
        }
    }
    Ok(out)
}

/// Given `{` at position `open`, return the index of the closing `}` if the span
/// forms a valid placeholder: `{name}` where name is one-or-more non-brace chars.
/// Regex equivalent of `\{([^{}]+)\}`.
fn find_placeholder_close(chars: &[char], open: usize) -> Option<usize> {
    let mut j = open + 1;
    let mut count = 0;
    while j < chars.len() {
        match chars[j] {
            '{' => return None, // brace inside — not a placeholder
            '}' => {
                if count == 0 {
                    return None; // empty name `{}`
                }
                return Some(j);
            }
            _ => {
                count += 1;
                j += 1;
            }
        }
    }
    None
}

fn resolve(
    template: &str,
    name: &str,
    params: &[(String, Value)],
) -> Result<String, TemplateFailure> {
    if name.starts_with("result.") || name.starts_with("item.") {
        return fail(
            TemplateFailureCode::ResultScopeInCore,
            format!("template references '{{{name}}}' resolved by a different layer, not core render_template"),
        );
    }
    match params.iter().find(|(k, _)| k == name) {
        Some((_, Value::Null)) | None => fail(
            TemplateFailureCode::UnboundParam,
            format!("template '{template}' references unbound parameter '{name}'"),
        ),
        Some((_, v)) => stringify(v),
    }
}

/// param value → string. Parity SSoT = Python str(value).
fn stringify(v: &Value) -> Result<String, TemplateFailure> {
    match v {
        Value::Str(s) => Ok(s.clone()),
        Value::Bool(b) => Ok(if *b { "True".into() } else { "False".into() }),
        Value::Int(i) => Ok(i.to_string()),
        Value::Float(f) => py_float_repr(*f).map_err(|_| TemplateFailure {
            code: TemplateFailureCode::InvalidStringify,
            message: format!("cannot stringify non-finite float {f}"),
        }),
        other => fail(
            TemplateFailureCode::InvalidStringify,
            format!("cannot stringify {} into a template", other.type_name()),
        ),
    }
}

/// resolvePartial — explain/dry-run variant: substitutes `{param}`, leaves
/// `{result.*}` and unbound params in place (template-rendering.md §3.3).
pub fn resolve_partial(template: &str, params: &[(String, Value)]) -> String {
    let chars: Vec<char> = template.chars().collect();
    let mut out = String::with_capacity(template.len());
    let mut i = 0;
    while i < chars.len() {
        if chars[i] == '{' {
            if let Some(close) = find_placeholder_close(&chars, i) {
                let name: String = chars[i + 1..close].iter().collect();
                if name.starts_with("result.") || name.starts_with("item.") {
                    out.push_str(&chars[i..=close].iter().collect::<String>());
                    i = close + 1;
                    continue;
                }
                match params.iter().find(|(k, _)| k == &name) {
                    Some((_, v)) if !matches!(v, Value::Null) => {
                        if let Ok(s) = stringify(v) {
                            out.push_str(&s);
                        } else {
                            out.push_str(&chars[i..=close].iter().collect::<String>());
                        }
                    }
                    _ => out.push_str(&chars[i..=close].iter().collect::<String>()),
                }
                i = close + 1;
                continue;
            }
        }
        out.push(chars[i]);
        i += 1;
    }
    out
}