Skip to main content

rustev_core/template/
mod.rs

1//! Pure `rustev.template/1` language machinery (spec 037, 3.3 and 3.4).
2//! This module does not construct contract documents or authorize generation.
3//! Callers supply resolved static types and exact values; graph provenance and
4//! parameter binding checks belong to the future generation compiler.
5mod check;
6mod parse;
7mod render;
8
9use rustev_contract::{decimal::Decimal, time::Timestamp};
10use std::collections::{BTreeMap, BTreeSet};
11
12pub const LANGUAGE: &str = "rustev.template/1";
13pub const HELPERS: &str = "rustev.template-helpers/1";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Error {
17    Syntax,
18    SourceBound,
19    Nesting,
20    InvalidBind,
21    UnknownPath(String),
22    ModelOutputFlow(String),
23    NotExact(String),
24    Type(String),
25    InvalidDeclaration,
26    UnsafeRaw,
27    InvalidCurrency,
28    OutputBound,
29    MayBeEmpty,
30    Arithmetic,
31    MissingValue(String),
32    InvalidValue(String),
33    Canonical,
34}
35impl std::fmt::Display for Error {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "template: {self:?}")
38    }
39}
40impl std::error::Error for Error {}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Class {
44    Exact,
45    Model,
46    Mixed,
47    Generated,
48}
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Field {
51    pub kind: Type,
52    pub class: Class,
53}
54impl Field {
55    pub fn exact(kind: Type) -> Self {
56        Self {
57            kind,
58            class: Class::Exact,
59        }
60    }
61}
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Type {
64    Bool,
65    Integer,
66    Decimal,
67    Text {
68        max_bytes: usize,
69    },
70    Enum(Vec<String>),
71    Timestamp,
72    Maybe(Box<Field>),
73    List {
74        element: Box<Field>,
75        max_items: usize,
76    },
77    Record(BTreeMap<String, Field>),
78}
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum Value {
81    Bool(bool),
82    Integer(i64),
83    Decimal(Decimal),
84    Text(String),
85    Enum(String),
86    Timestamp(Timestamp),
87    Maybe(Option<Box<Value>>),
88    List(Vec<Value>),
89    Record(BTreeMap<String, Value>),
90}
91pub type Schema = BTreeMap<String, Field>;
92pub type Values = BTreeMap<String, Value>;
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Escape {
95    Text,
96    Html,
97}
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum Overflow {
100    Refuse,
101    Truncate,
102}
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum Lines {
105    Single,
106    Multi,
107}
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Locale {
110    Und,
111    EnUs,
112    EnGb,
113    DeDe,
114    FrFr,
115    EsEs,
116    PlPl,
117}
118impl Locale {
119    pub fn parse(s: &str) -> Result<Self, Error> {
120        match s {
121            "und" => Ok(Self::Und),
122            "en-US" => Ok(Self::EnUs),
123            "en-GB" => Ok(Self::EnGb),
124            "de-DE" => Ok(Self::DeDe),
125            "fr-FR" => Ok(Self::FrFr),
126            "es-ES" => Ok(Self::EsEs),
127            "pl-PL" => Ok(Self::PlPl),
128            _ => Err(Error::InvalidDeclaration),
129        }
130    }
131}
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum Mode {
134    Backstop { bind: Option<String> },
135    Prompt,
136}
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Options {
139    pub mode: Mode,
140    pub locale: Locale,
141    pub escape: Escape,
142    pub overflow: Overflow,
143    pub lines: Lines,
144    pub max_bytes: usize,
145    pub required: bool,
146}
147impl Options {
148    /// Prompt instructions use the locale-neutral helper table and retain LF.
149    pub fn prompt() -> Self {
150        Self {
151            mode: Mode::Prompt,
152            locale: Locale::Und,
153            escape: Escape::Text,
154            overflow: Overflow::Refuse,
155            lines: Lines::Multi,
156            max_bytes: 16384,
157            required: false,
158        }
159    }
160}
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct Bounds {
163    pub min: usize,
164    pub max: usize,
165}
166#[derive(Debug, Clone)]
167enum Node {
168    Text(String),
169    Emit(String, Format),
170    Section(String, bool, Vec<Node>),
171}
172#[derive(Debug, Clone)]
173enum Format {
174    Plain,
175    Raw,
176    Number(u32),
177    Money(u32, Currency),
178    Date(bool),
179    Join,
180}
181#[derive(Debug, Clone)]
182enum Currency {
183    Literal(String),
184    Path(String),
185}
186#[derive(Debug, Clone)]
187pub struct Template {
188    source: String,
189    nodes: Vec<Node>,
190    schema: Schema,
191    options: Options,
192    bounds: Bounds,
193    references: BTreeSet<String>,
194}
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Rendered {
197    pub text: String,
198    pub truncated: bool,
199    pub reads: BTreeSet<String>,
200}
201impl Template {
202    pub fn compile(source: &str, schema: Schema, options: Options) -> Result<Self, Error> {
203        check::options(&options, &schema)?;
204        let limit = if options.mode == Mode::Prompt {
205            16384
206        } else {
207            4096
208        };
209        if source.len() > limit {
210            return Err(Error::SourceBound);
211        }
212        let nodes = parse::parse(source)?;
213        let mut references = BTreeSet::new();
214        let bounds = check::nodes(&nodes, &schema, &options, &mut references)?;
215        if options.overflow == Overflow::Refuse && bounds.max > options.max_bytes {
216            return Err(Error::OutputBound);
217        }
218        if options.required && bounds.min == 0 {
219            return Err(Error::MayBeEmpty);
220        }
221        Ok(Self {
222            source: source.into(),
223            nodes,
224            schema,
225            options,
226            bounds,
227            references,
228        })
229    }
230    pub fn bounds(&self) -> Bounds {
231        self.bounds
232    }
233    pub fn references(&self) -> &BTreeSet<String> {
234        &self.references
235    }
236    /// Preserve source bytes, including comments and tag spaces: every authored
237    /// change must remain visible to the enclosing prompt document's identity.
238    pub fn canonical_source(&self) -> &str {
239        &self.source
240    }
241    pub fn render(&self, values: &Values) -> Result<Rendered, Error> {
242        render::render(self, values)
243    }
244}
245/// Canonicalize the complete supplied prompt document using the existing
246/// contract writer. This does not validate or introduce a contract document type.
247pub fn prompt_canonical<T: serde::Serialize>(document: &T) -> Result<Vec<u8>, Error> {
248    rustev_contract::canonical::canonical_bytes(document).map_err(|_| Error::Canonical)
249}
250/// Digest a complete prompt document, including id, version and declarations.
251/// The caller is responsible for document validation in the compilation layer.
252pub fn prompt_digest<T: serde::Serialize>(document: &T) -> Result<String, Error> {
253    Ok(rustev_contract::canonical::tagged_digest(
254        "rustev.prompt-template/1",
255        &prompt_canonical(document)?,
256    ))
257}
258/// Check a literal list backstop against the resolved field vocabulary.
259pub fn list_backstop(
260    options: &[String],
261    vocabulary: &[String],
262    max_items: usize,
263    required: bool,
264) -> Result<Vec<String>, Error> {
265    if !(1..=64).contains(&max_items)
266        || vocabulary.is_empty()
267        || vocabulary.len() > 256
268        || vocabulary.iter().any(|s| s.is_empty() || s.len() > 64)
269        || vocabulary.iter().collect::<BTreeSet<_>>().len() != vocabulary.len()
270    {
271        return Err(Error::InvalidDeclaration);
272    }
273    if required && options.is_empty() {
274        return Err(Error::MayBeEmpty);
275    }
276    if options.len() > max_items {
277        return Err(Error::OutputBound);
278    }
279    if options.iter().collect::<BTreeSet<_>>().len() != options.len()
280        || options.iter().any(|s| !vocabulary.contains(s))
281    {
282        return Err(Error::InvalidDeclaration);
283    }
284    Ok(options.to_vec())
285}
286/// Pure syntax checks for the future prompt document's id and version.
287/// Versions are three canonical unsigned decimal components, without suffixes.
288pub fn prompt_header(id: &str, version: &str) -> Result<(), Error> {
289    let valid_id = (1..=128).contains(&id.len())
290        && id
291            .bytes()
292            .next()
293            .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
294        && id
295            .bytes()
296            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b));
297    let parts: Vec<_> = version.split('.').collect();
298    if !valid_id
299        || parts.len() != 3
300        || parts.iter().any(|s| {
301            s.is_empty()
302                || !s.bytes().all(|b| b.is_ascii_digit())
303                || (s.len() > 1 && s.starts_with('0'))
304        })
305    {
306        return Err(Error::InvalidDeclaration);
307    }
308    Ok(())
309}
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub enum BindingSource {
312    Literal,
313    Profile,
314    ExactReference,
315}
316/// Check a resolved parameter binding's type and class. Reference location and
317/// graph lineage are checked by the future compiler before calling this helper.
318pub fn parameter_binding(
319    parameter: &Type,
320    supplied: &Field,
321    source: BindingSource,
322) -> Result<(), Error> {
323    if supplied.class == Class::Generated {
324        return Err(Error::ModelOutputFlow("parameter".into()));
325    }
326    if supplied.class != Class::Exact {
327        return Err(Error::NotExact("parameter".into()));
328    }
329    let matches = match (parameter, &supplied.kind) {
330        (Type::Bool, Type::Bool)
331        | (Type::Integer, Type::Integer)
332        | (Type::Decimal, Type::Decimal) => true,
333        (Type::Enum(options), Type::Enum(actual)) => {
334            !actual.is_empty() && actual.iter().all(|s| options.contains(s))
335        }
336        (Type::Text { max_bytes }, Type::Text { max_bytes: actual }) => {
337            source != BindingSource::ExactReference && *max_bytes <= 1024 && actual <= max_bytes
338        }
339        _ => false,
340    };
341    if matches {
342        Ok(())
343    } else {
344        Err(Error::Type("parameter".into()))
345    }
346}