Skip to main content

behavior_contracts/
template.rs

1//! template — template-rendering.md reference implementation.
2//!
3//! render_template(template, params): replace `{param}` placeholders with bound
4//! values (strict). `{result.*}` / `{item.*}` are resolved by a different layer
5//! and are out-of-scope Failures here.
6//!
7//! Normative points:
8//!   - placeholder = `{` + one-or-more non-brace chars + `}` (no escaping).
9//!   - single left-to-right pass; substituted text is not re-processed.
10//!   - strict missing-key: unbound param → Failure (never empty string).
11//!   - null param → Failure (present = key in params && value != null).
12//!   - stringify follows Python str(value): bool → True/False, float → py_float_repr.
13
14use crate::canonical::py_float_repr;
15use crate::value::Value;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TemplateFailureCode {
19    UnboundParam,
20    ResultScopeInCore,
21    InvalidStringify,
22}
23
24impl TemplateFailureCode {
25    pub fn as_str(self) -> &'static str {
26        match self {
27            TemplateFailureCode::UnboundParam => "UNBOUND_PARAM",
28            TemplateFailureCode::ResultScopeInCore => "RESULT_SCOPE_IN_CORE",
29            TemplateFailureCode::InvalidStringify => "INVALID_STRINGIFY",
30        }
31    }
32}
33
34#[derive(Debug, Clone)]
35pub struct TemplateFailure {
36    pub code: TemplateFailureCode,
37    pub message: String,
38}
39
40impl std::fmt::Display for TemplateFailure {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        write!(f, "{}: {}", self.code.as_str(), self.message)
43    }
44}
45impl std::error::Error for TemplateFailure {}
46
47fn fail<T>(code: TemplateFailureCode, message: impl Into<String>) -> Result<T, TemplateFailure> {
48    Err(TemplateFailure {
49        code,
50        message: message.into(),
51    })
52}
53
54/// Render `{param}` placeholders strictly.
55pub fn render_template(
56    template: &str,
57    params: &[(String, Value)],
58) -> Result<String, TemplateFailure> {
59    let mut out = String::with_capacity(template.len());
60    let chars: Vec<char> = template.chars().collect();
61    let mut i = 0;
62    while i < chars.len() {
63        let c = chars[i];
64        if c == '{' {
65            // find matching `}` with no brace inside and non-empty name.
66            if let Some(close) = find_placeholder_close(&chars, i) {
67                let name: String = chars[i + 1..close].iter().collect();
68                // name is guaranteed non-empty and brace-free by find_placeholder_close
69                out.push_str(&resolve(template, &name, params)?);
70                i = close + 1;
71                continue;
72            }
73            // not a placeholder ({} or unmatched): emit literally.
74            out.push(c);
75            i += 1;
76        } else {
77            out.push(c);
78            i += 1;
79        }
80    }
81    Ok(out)
82}
83
84/// Given `{` at position `open`, return the index of the closing `}` if the span
85/// forms a valid placeholder: `{name}` where name is one-or-more non-brace chars.
86/// Regex equivalent of `\{([^{}]+)\}`.
87fn find_placeholder_close(chars: &[char], open: usize) -> Option<usize> {
88    let mut j = open + 1;
89    let mut count = 0;
90    while j < chars.len() {
91        match chars[j] {
92            '{' => return None, // brace inside — not a placeholder
93            '}' => {
94                if count == 0 {
95                    return None; // empty name `{}`
96                }
97                return Some(j);
98            }
99            _ => {
100                count += 1;
101                j += 1;
102            }
103        }
104    }
105    None
106}
107
108fn resolve(
109    template: &str,
110    name: &str,
111    params: &[(String, Value)],
112) -> Result<String, TemplateFailure> {
113    if name.starts_with("result.") || name.starts_with("item.") {
114        return fail(
115            TemplateFailureCode::ResultScopeInCore,
116            format!("template references '{{{name}}}' resolved by a different layer, not core render_template"),
117        );
118    }
119    match params.iter().find(|(k, _)| k == name) {
120        Some((_, Value::Null)) | None => fail(
121            TemplateFailureCode::UnboundParam,
122            format!("template '{template}' references unbound parameter '{name}'"),
123        ),
124        Some((_, v)) => stringify(v),
125    }
126}
127
128/// param value → string. Parity SSoT = Python str(value).
129fn stringify(v: &Value) -> Result<String, TemplateFailure> {
130    match v {
131        Value::Str(s) => Ok(s.clone()),
132        Value::Bool(b) => Ok(if *b { "True".into() } else { "False".into() }),
133        Value::Int(i) => Ok(i.to_string()),
134        Value::Float(f) => py_float_repr(*f).map_err(|_| TemplateFailure {
135            code: TemplateFailureCode::InvalidStringify,
136            message: format!("cannot stringify non-finite float {f}"),
137        }),
138        other => fail(
139            TemplateFailureCode::InvalidStringify,
140            format!("cannot stringify {} into a template", other.type_name()),
141        ),
142    }
143}
144
145/// resolvePartial — explain/dry-run variant: substitutes `{param}`, leaves
146/// `{result.*}` and unbound params in place (template-rendering.md §3.3).
147pub fn resolve_partial(template: &str, params: &[(String, Value)]) -> String {
148    let chars: Vec<char> = template.chars().collect();
149    let mut out = String::with_capacity(template.len());
150    let mut i = 0;
151    while i < chars.len() {
152        if chars[i] == '{' {
153            if let Some(close) = find_placeholder_close(&chars, i) {
154                let name: String = chars[i + 1..close].iter().collect();
155                if name.starts_with("result.") || name.starts_with("item.") {
156                    out.push_str(&chars[i..=close].iter().collect::<String>());
157                    i = close + 1;
158                    continue;
159                }
160                match params.iter().find(|(k, _)| k == &name) {
161                    Some((_, v)) if !matches!(v, Value::Null) => {
162                        if let Ok(s) = stringify(v) {
163                            out.push_str(&s);
164                        } else {
165                            out.push_str(&chars[i..=close].iter().collect::<String>());
166                        }
167                    }
168                    _ => out.push_str(&chars[i..=close].iter().collect::<String>()),
169                }
170                i = close + 1;
171                continue;
172            }
173        }
174        out.push(chars[i]);
175        i += 1;
176    }
177    out
178}