use std::collections::HashMap;
use super::errors::{CalError, CalResult, Span};
use super::executor::CalGrainResult;
pub const MAX_TEMPLATE_SIZE: usize = 4_096;
pub const MAX_TEMPLATE_NESTING: usize = 5;
pub const MAX_EACH_ITERATIONS: usize = 200;
pub const MAX_TEMPLATES: usize = 50;
pub const MAX_RENDER_OUTPUT_SIZE: usize = 1_048_576;
const FORMAT_ARM_NAMES: &[&str] = &[
"json", "yaml", "text", "sml", "toon", "markdown", "csv", "table", "triples",
];
const ALLOWED_FIELDS: &[&str] = &[
"subject",
"relation",
"object",
"namespace",
"user_id",
"confidence",
"importance",
"created_at",
"tags",
"session_id",
"content",
"summary",
"source_hash",
"source_hashes",
"actor",
"action",
"participants",
"entity",
"state_value",
"validity",
"name",
"nodes",
"edges",
"bindings",
"retries",
"trigger",
"node_count",
"edge_count",
"status",
"tool_name",
"input",
"is_error",
"duration_ms",
"output_schema",
"kind",
"tool_description",
"input_schema",
"executor_uri",
"runtime",
"runtime_limits",
"locked_params",
"examples",
"annotations",
"spec_hash",
"tool_call_id",
"call_batch_id",
"observer",
"observed",
"sensory_data",
"sensor",
"value",
"unit",
"title",
"description",
"goal_state",
"assigned_to",
"priority",
"parent_hash",
"premises",
"conclusion",
"agreement_level",
"agreement",
"consenter",
"scope",
"granted",
"expires_at",
"grantee_did",
"subject_did",
"is_withdrawal",
"basis",
];
const SPEC_GRAIN_VARIABLES: &[&str] = &[
"content",
"type",
"subject",
"relation",
"humanized_relation",
"object",
"confidence",
"importance",
"tags",
"created_at",
"relative_time",
"score",
"hash",
"is_full",
"is_summary",
];
const SPEC_ASSEMBLY_VARIABLES: &[&str] = &["name", "intent", "source_count", "grain_count"];
const SPEC_BUDGET_VARIABLES: &[&str] =
&["total", "used", "remaining", "unit", "utilization"];
const SPEC_SOURCE_VARIABLES: &[&str] = &[
"label",
"index",
"priority",
"grain_count",
"tokens_used",
"truncated",
];
const ALL_VALID_VARIABLES: &[&str] = &[
"hash",
"grain_type",
"score",
"_index",
"_count",
"_first",
"_last",
"_now",
"subject",
"relation",
"object",
"namespace",
"user_id",
"confidence",
"importance",
"created_at",
"tags",
"session_id",
"content",
"summary",
"source_hash",
"source_hashes",
"actor",
"action",
"participants",
"entity",
"state_value",
"validity",
"name",
"nodes",
"edges",
"bindings",
"retries",
"trigger",
"node_count",
"edge_count",
"status",
"tool_name",
"input",
"is_error",
"duration_ms",
"output_schema",
"kind",
"tool_description",
"input_schema",
"executor_uri",
"runtime",
"runtime_limits",
"locked_params",
"examples",
"annotations",
"spec_hash",
"tool_call_id",
"call_batch_id",
"observer",
"observed",
"sensory_data",
"sensor",
"value",
"unit",
"title",
"description",
"goal_state",
"assigned_to",
"priority",
"parent_hash",
"premises",
"conclusion",
"agreement_level",
"agreement",
"consenter",
"scope",
"granted",
"expires_at",
"grantee_did",
"subject_did",
"is_withdrawal",
"basis",
];
const KNOWN_FILTERS: &[&str] = &[
"truncate",
"date",
"relative",
"humanize",
"percent",
"uppercase",
"lowercase",
"json",
"default",
"join",
];
#[derive(Debug, Clone, PartialEq)]
pub enum TemplateNode {
Text(String),
Variable {
name: String,
filters: Vec<Filter>,
},
Each {
collection: String,
body: Vec<TemplateNode>,
},
Condition {
field: String,
if_body: Vec<TemplateNode>,
else_body: Vec<TemplateNode>,
},
InvertedSection {
field: String,
body: Vec<TemplateNode>,
},
GrainTypeBlock {
grain_type: String,
body: Vec<TemplateNode>,
},
Comment(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Filter {
pub name: String,
pub arg: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TemplateSections {
pub header: Option<Vec<TemplateNode>>,
pub element: Option<Vec<TemplateNode>>,
pub element_summary: Option<Vec<TemplateNode>>,
pub element_omit: Option<Vec<TemplateNode>>,
pub source_break: Option<Vec<TemplateNode>>,
pub footer: Option<Vec<TemplateNode>>,
}
impl TemplateSections {
pub fn is_empty(&self) -> bool {
self.header.is_none()
&& self.element.is_none()
&& self.element_summary.is_none()
&& self.element_omit.is_none()
&& self.source_break.is_none()
&& self.footer.is_none()
}
fn inherit_from(&mut self, parent: &TemplateSections) {
fn fill(child: &mut Option<Vec<TemplateNode>>, parent: &Option<Vec<TemplateNode>>) {
if child.is_none() {
child.clone_from(parent);
}
}
fill(&mut self.header, &parent.header);
fill(&mut self.element, &parent.element);
fill(&mut self.element_summary, &parent.element_summary);
fill(&mut self.element_omit, &parent.element_omit);
fill(&mut self.source_break, &parent.source_break);
fill(&mut self.footer, &parent.footer);
}
}
#[derive(Debug, Clone, Copy)]
pub struct RenderSource<'a> {
pub label: &'a str,
pub grains: &'a [CalGrainResult],
pub omitted: &'a [CalGrainResult],
pub priority: usize,
pub tokens_used: usize,
pub truncated: bool,
}
impl<'a> RenderSource<'a> {
pub fn single(grains: &'a [CalGrainResult]) -> Self {
Self {
label: "",
grains,
omitted: &[],
priority: 0,
tokens_used: 0,
truncated: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AssemblyContext {
pub name: String,
pub intent: String,
pub source_count: usize,
pub grain_count: usize,
pub budget_total: u64,
pub budget_used: u64,
pub budget_unit: String,
}
impl AssemblyContext {
fn budget_remaining(&self) -> u64 {
self.budget_total.saturating_sub(self.budget_used)
}
fn budget_utilization(&self) -> f64 {
if self.budget_total == 0 {
0.0
} else {
self.budget_used as f64 / self.budget_total as f64
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RenderPlan<'a> {
pub assembly: Option<&'a AssemblyContext>,
pub sources: Option<&'a [RenderSource<'a>]>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RenderEnv<'a> {
pub assembly: Option<&'a AssemblyContext>,
pub source: Option<(&'a RenderSource<'a>, usize)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Template {
source: String,
nodes: Vec<TemplateNode>,
sections: Option<Box<TemplateSections>>,
}
impl Template {
pub fn source(&self) -> &str {
&self.source
}
pub fn nodes(&self) -> &[TemplateNode] {
&self.nodes
}
pub fn sections(&self) -> Option<&TemplateSections> {
self.sections.as_deref()
}
pub fn is_sectioned(&self) -> bool {
self.sections.is_some()
}
pub fn from_sections(source: impl Into<String>, sections: TemplateSections) -> Self {
Self {
source: source.into(),
nodes: Vec::new(),
sections: Some(Box::new(sections)),
}
}
}
#[derive(Debug, Clone)]
pub enum ResolvedValue {
Str(String),
Number(f64),
Integer(i64),
Bool(bool),
Array(Vec<String>),
Null,
}
impl ResolvedValue {
pub fn is_truthy(&self) -> bool {
match self {
Self::Str(s) => !s.is_empty(),
Self::Number(n) => *n != 0.0,
Self::Integer(n) => *n != 0,
Self::Bool(b) => *b,
Self::Array(a) => !a.is_empty(),
Self::Null => false,
}
}
pub fn to_display(&self) -> String {
match self {
Self::Str(s) => s.clone(),
Self::Number(n) => {
if *n == (*n as i64) as f64 {
format!("{}", *n as i64)
} else {
format!("{:.2}", n)
}
}
Self::Integer(n) => format!("{}", n),
Self::Bool(b) => if *b { "true" } else { "false" }.to_string(),
Self::Array(a) => a.join(", "),
Self::Null => String::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisclosureTier {
Summary,
Headlines,
Full,
}
#[derive(Debug, Clone)]
pub struct RenderContext {
pub now_secs: i64,
pub tier: DisclosureTier,
pub total_count: usize,
pub user_vars: HashMap<String, String>,
}
pub fn is_valid_template_name(name: &str) -> bool {
if name.is_empty() || name.len() > 64 {
return false;
}
if name != name.trim() || name.contains(" ") {
return false;
}
let bytes = name.as_bytes();
if !bytes[0].is_ascii_alphabetic() {
return false;
}
for &b in &bytes[1..] {
if !(b.is_ascii_alphanumeric() || b == b' ' || b == b'-' || b == b'_') {
return false;
}
}
true
}
pub fn parse_template(source: &str) -> CalResult<Template> {
if source.len() > MAX_TEMPLATE_SIZE {
return Err(CalError::TemplateTooLarge {
size: source.len(),
max: MAX_TEMPLATE_SIZE,
span: Some(Span::zero()),
});
}
let chars: Vec<char> = source.chars().collect();
let nodes = parse_nodes(&chars, 0, &mut 0, 0, false, None)?;
validate_variables(&nodes, None)?;
Ok(Template {
source: source.to_string(),
nodes,
sections: None,
})
}
pub fn parse_sectioned_source(source: &str) -> Option<crate::ast::TemplateSectionSources> {
use crate::lexer::{Lexer, SectionBody, Token};
let tokens = Lexer::tokenize(source).ok()?;
if tokens.is_empty() {
return None;
}
let mut out = crate::ast::TemplateSectionSources::default();
for st in tokens {
let (slot, body) = match &st.token {
Token::Header(b) => (&mut out.header, b),
Token::Element(b) => (&mut out.element, b),
Token::ElementSummary(b) => (&mut out.element_summary, b),
Token::ElementOmit(b) => (&mut out.element_omit, b),
Token::SourceBreak(b) => (&mut out.source_break, b),
Token::Footer(b) => (&mut out.footer, b),
_ => return None,
};
match body {
SectionBody::Body(text) => *slot = Some(text.clone()),
SectionBody::Bare => return None,
}
}
Some(out)
}
pub fn parse_sections(src: &crate::ast::TemplateSectionSources) -> CalResult<TemplateSections> {
let parse = |s: &Option<String>| -> CalResult<Option<Vec<TemplateNode>>> {
match s {
Some(text) => Ok(Some(parse_section(text)?)),
None => Ok(None),
}
};
Ok(TemplateSections {
header: parse(&src.header)?,
element: parse(&src.element)?,
element_summary: parse(&src.element_summary)?,
element_omit: parse(&src.element_omit)?,
source_break: parse(&src.source_break)?,
footer: parse(&src.footer)?,
})
}
pub fn parse_template_any(source: &str) -> CalResult<Template> {
match parse_sectioned_source(source) {
Some(src) => Ok(Template::from_sections(source, parse_sections(&src)?)),
None => parse_template(source),
}
}
pub fn parse_section(source: &str) -> CalResult<Vec<TemplateNode>> {
if source.len() > MAX_TEMPLATE_SIZE {
return Err(CalError::TemplateTooLarge {
size: source.len(),
max: MAX_TEMPLATE_SIZE,
span: Some(Span::zero()),
});
}
let chars: Vec<char> = source.chars().collect();
let nodes = parse_nodes(&chars, 0, &mut 0, 0, false, None)?;
validate_variables(&nodes, None)?;
Ok(nodes)
}
fn parse_nodes(
chars: &[char],
start: usize,
pos: &mut usize,
depth: usize,
in_each: bool,
stop_tag: Option<&str>,
) -> CalResult<Vec<TemplateNode>> {
if depth > MAX_TEMPLATE_NESTING {
return Err(CalError::TemplateNestingTooDeep {
max: MAX_TEMPLATE_NESTING,
span: Some(make_span(start, start)),
});
}
let mut nodes = Vec::new();
*pos = start;
while *pos < chars.len() {
if *pos + 1 < chars.len() && chars[*pos] == '{' && chars[*pos + 1] == '{' {
let tag_start = *pos;
let tag_content = read_tag(chars, pos)?;
let trimmed = tag_content.trim();
if let Some(stripped) = trimmed.strip_prefix('!') {
let comment_text = stripped.trim();
nodes.push(TemplateNode::Comment(comment_text.to_string()));
continue;
}
if let Some(stripped) = trimmed.strip_prefix('/') {
let close_name = stripped.trim();
if let Some(expected) = stop_tag {
if close_name == expected {
return Ok(nodes);
}
}
return Err(CalError::TemplateSyntaxError {
detail: format!("unexpected closing tag \"{}\"", close_name),
span: Some(make_span(tag_start, *pos)),
});
}
if trimmed == "else" {
if let Some(expected) = stop_tag {
if expected == "if" || expected.starts_with("if:") {
nodes.push(TemplateNode::Comment("__else__".to_string()));
return Ok(nodes);
}
}
return Err(CalError::TemplateSyntaxError {
detail: "{{else}} without matching {{#if}}".to_string(),
span: Some(make_span(tag_start, *pos)),
});
}
if let Some(stripped) = trimmed.strip_prefix('#') {
let section = stripped.trim();
if section.starts_with("each ") || section == "each" {
if in_each {
return Err(CalError::TemplateNestedEach {
span: Some(make_span(tag_start, *pos)),
});
}
let collection = section.strip_prefix("each").unwrap_or("").trim();
let collection = if collection.is_empty() {
"grains"
} else {
collection
};
if collection != "grains" {
return Err(CalError::TemplateSyntaxError {
detail: format!(
"{{{{#each}}}} only supports \"grains\", got \"{}\"",
collection
),
span: Some(make_span(tag_start, *pos)),
});
}
let body = parse_nodes(chars, *pos, pos, depth + 1, true, Some("each"))?;
nodes.push(TemplateNode::Each {
collection: collection.to_string(),
body,
});
} else if section.starts_with("if ") || section == "if" {
let field = section.strip_prefix("if").unwrap_or("").trim().to_string();
if field.is_empty() {
return Err(CalError::TemplateSyntaxError {
detail: "{{#if}} requires a field name".to_string(),
span: Some(make_span(tag_start, *pos)),
});
}
let if_body = parse_nodes(chars, *pos, pos, depth + 1, in_each, Some("if"))?;
let has_else = if_body
.last()
.is_some_and(|n| matches!(n, TemplateNode::Comment(c) if c == "__else__"));
let (if_body, else_body) = if has_else {
let if_nodes: Vec<TemplateNode> = if_body
.into_iter()
.filter(|n| !matches!(n, TemplateNode::Comment(c) if c == "__else__"))
.collect();
let else_body = parse_nodes(chars, *pos, pos, depth + 1, in_each, Some("if"))?;
(if_nodes, else_body)
} else {
(if_body, Vec::new())
};
nodes.push(TemplateNode::Condition {
field,
if_body,
else_body,
});
} else if section.starts_with("grain_type ") {
let rest = section.strip_prefix("grain_type").unwrap_or("").trim();
let grain_type =
unquote(rest).ok_or_else(|| CalError::TemplateSyntaxError {
detail: format!(
"{{{{#grain_type}}}} requires a quoted type name, got \"{}\"",
rest
),
span: Some(make_span(tag_start, *pos)),
})?;
let body = parse_nodes(chars, *pos, pos, depth + 1, in_each, Some("grain_type"))?;
nodes.push(TemplateNode::GrainTypeBlock { grain_type, body });
} else {
let field = section.to_string();
if field.contains(char::is_whitespace) {
return Err(CalError::TemplateSyntaxError {
detail: format!("unknown section type \"{}\"", section),
span: Some(make_span(tag_start, *pos)),
});
}
validate_variable_name(&field, None)?;
let if_body = parse_nodes(chars, *pos, pos, depth + 1, in_each, Some(&field))?;
nodes.push(TemplateNode::Condition {
field,
if_body,
else_body: Vec::new(),
});
}
continue;
}
if let Some(stripped) = trimmed.strip_prefix('^') {
let field = stripped.trim().to_string();
if field.is_empty() {
return Err(CalError::TemplateSyntaxError {
detail: "{{^}} requires a field name".to_string(),
span: Some(make_span(tag_start, *pos)),
});
}
let body = parse_nodes(chars, *pos, pos, depth + 1, in_each, Some(&field))?;
nodes.push(TemplateNode::InvertedSection { field, body });
continue;
}
let (var_name, filters) = parse_variable_and_filters(trimmed, tag_start, *pos)?;
nodes.push(TemplateNode::Variable {
name: var_name,
filters,
});
} else {
let text_start = *pos;
while *pos < chars.len() {
if *pos + 1 < chars.len() && chars[*pos] == '{' && chars[*pos + 1] == '{' {
break;
}
*pos += 1;
}
let text: String = chars[text_start..*pos].iter().collect();
if !text.is_empty() {
nodes.push(TemplateNode::Text(text));
}
}
}
if let Some(expected) = stop_tag {
return Err(CalError::TemplateSyntaxError {
detail: format!("unclosed {{{{#{}}}}} block", expected),
span: Some(make_span(start, chars.len())),
});
}
Ok(nodes)
}
fn read_tag(chars: &[char], pos: &mut usize) -> CalResult<String> {
let tag_start = *pos;
*pos += 2;
let content_start = *pos;
let mut depth = 1;
while *pos < chars.len() {
if *pos + 1 < chars.len() && chars[*pos] == '}' && chars[*pos + 1] == '}' {
depth -= 1;
if depth == 0 {
let content: String = chars[content_start..*pos].iter().collect();
*pos += 2; return Ok(content);
}
}
if *pos + 1 < chars.len() && chars[*pos] == '{' && chars[*pos + 1] == '{' {
depth += 1;
*pos += 2;
continue;
}
*pos += 1;
}
Err(CalError::TemplateSyntaxError {
detail: "unclosed template tag (missing `}}`)".to_string(),
span: Some(make_span(tag_start, *pos)),
})
}
fn parse_variable_and_filters(
expr: &str,
tag_start: usize,
tag_end: usize,
) -> CalResult<(String, Vec<Filter>)> {
let parts = split_pipe(expr);
if parts.is_empty() {
return Err(CalError::TemplateSyntaxError {
detail: "empty variable expression".to_string(),
span: Some(make_span(tag_start, tag_end)),
});
}
let var_name = parts[0].trim().to_string();
if var_name.is_empty() {
return Err(CalError::TemplateSyntaxError {
detail: "empty variable name".to_string(),
span: Some(make_span(tag_start, tag_end)),
});
}
let mut filters = Vec::new();
for part in &parts[1..] {
let filter = parse_single_filter(part.trim(), tag_start, tag_end)?;
filters.push(filter);
}
Ok((var_name, filters))
}
fn parse_single_filter(segment: &str, tag_start: usize, tag_end: usize) -> CalResult<Filter> {
let segment = segment.trim();
if segment.is_empty() {
return Err(CalError::TemplateSyntaxError {
detail: "empty filter name".to_string(),
span: Some(make_span(tag_start, tag_end)),
});
}
let (name, arg) = if let Some(paren_idx) = segment.find('(') {
let name = segment[..paren_idx].trim();
let rest = &segment[paren_idx + 1..];
let close = rest.rfind(')').unwrap_or(rest.len());
let raw_arg = rest[..close].trim();
let arg = unquote(raw_arg).unwrap_or_else(|| raw_arg.to_string());
(name, Some(arg))
} else {
match segment.find(|c: char| c.is_whitespace()) {
Some(idx) => {
let name = &segment[..idx];
let raw_arg = segment[idx..].trim();
let arg = unquote(raw_arg).unwrap_or_else(|| raw_arg.to_string());
(name, Some(arg))
}
None => (segment, None),
}
};
if !KNOWN_FILTERS.contains(&name) {
return Err(CalError::TemplateUnknownFilter {
name: name.to_string(),
span: Some(make_span(tag_start, tag_end)),
});
}
match name {
"truncate" => {
if let Some(ref a) = arg {
match a.parse::<usize>() {
Ok(n) if (1..=100_000).contains(&n) => {}
Ok(n) => {
return Err(CalError::TemplateSyntaxError {
detail: format!("truncate argument {} out of range [1, 100000]", n),
span: Some(make_span(tag_start, tag_end)),
});
}
Err(_) => {
return Err(CalError::TemplateSyntaxError {
detail: format!("truncate argument \"{}\" is not a valid number", a),
span: Some(make_span(tag_start, tag_end)),
});
}
}
}
}
"date" => {
if let Some(ref a) = arg {
if a.len() > 256 {
return Err(CalError::TemplateSyntaxError {
detail: format!("date format string too long ({} chars, max 256)", a.len()),
span: Some(make_span(tag_start, tag_end)),
});
}
}
}
_ => {}
}
Ok(Filter {
name: name.to_string(),
arg,
})
}
fn split_pipe(expr: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut quote_char = '"';
for ch in expr.chars() {
if in_quote {
current.push(ch);
if ch == quote_char {
in_quote = false;
}
} else if ch == '"' || ch == '\'' {
in_quote = true;
quote_char = ch;
current.push(ch);
} else if ch == '|' {
parts.push(current.clone());
current.clear();
} else {
current.push(ch);
}
}
if !current.is_empty() || parts.is_empty() {
parts.push(current);
}
parts
}
fn unquote(s: &str) -> Option<String> {
let s = s.trim();
if s.len() >= 2
&& ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
{
return Some(s[1..s.len() - 1].to_string());
}
if !s.is_empty() {
Some(s.to_string())
} else {
None
}
}
fn make_span(start: usize, end: usize) -> Span {
Span::new(start, end, 1, start + 1)
}
fn validate_variables(nodes: &[TemplateNode], grain_type_ctx: Option<&str>) -> CalResult<()> {
for node in nodes {
match node {
TemplateNode::Variable { name, .. } => {
validate_variable_name(name, grain_type_ctx)?;
}
TemplateNode::Each { body, .. } => {
validate_variables(body, grain_type_ctx)?;
}
TemplateNode::Condition {
field,
if_body,
else_body,
} => {
validate_variable_name(field, grain_type_ctx)?;
validate_variables(if_body, grain_type_ctx)?;
validate_variables(else_body, grain_type_ctx)?;
}
TemplateNode::InvertedSection { field, body } => {
validate_variable_name(field, grain_type_ctx)?;
validate_variables(body, grain_type_ctx)?;
}
TemplateNode::GrainTypeBlock { grain_type, body } => {
validate_variables(body, Some(grain_type))?;
}
TemplateNode::Text(_) | TemplateNode::Comment(_) => {}
}
}
Ok(())
}
fn validate_variable_name(name: &str, _grain_type_ctx: Option<&str>) -> CalResult<()> {
if let Some(stripped) = name.strip_prefix('$') {
if !stripped.is_empty()
&& (stripped.as_bytes()[0].is_ascii_alphabetic() || stripped.as_bytes()[0] == b'_')
&& stripped
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return Ok(());
}
return Err(CalError::TemplateUnknownVariable {
name: name.to_string(),
span: None,
suggestion: Some("user variable names must match $[a-zA-Z_][a-zA-Z0-9_]*".into()),
});
}
if ALL_VALID_VARIABLES.contains(&name) {
return Ok(());
}
if matches!(name, "disclosure.level" | "timestamp") {
return Ok(());
}
if let Some(field) = name.strip_prefix("grain.") {
if SPEC_GRAIN_VARIABLES.contains(&field) || ALLOWED_FIELDS.contains(&field) {
return Ok(());
}
}
if let Some(field) = name.strip_prefix("assembly.") {
if SPEC_ASSEMBLY_VARIABLES.contains(&field) {
return Ok(());
}
}
if let Some(field) = name.strip_prefix("budget.") {
if SPEC_BUDGET_VARIABLES.contains(&field) {
return Ok(());
}
}
if let Some(field) = name.strip_prefix("source.") {
if SPEC_SOURCE_VARIABLES.contains(&field) {
return Ok(());
}
}
let suggestion = find_closest_variable(name);
Err(CalError::TemplateUnknownVariable {
name: name.to_string(),
span: None,
suggestion,
})
}
fn find_closest_variable(name: &str) -> Option<String> {
let mut best: Option<(&str, usize)> = None;
for &var in ALL_VALID_VARIABLES {
let dist = simple_edit_distance(name, var);
if dist <= 3 && best.is_none_or(|(_, d)| dist < d) {
best = Some((var, dist));
}
}
best.map(|(v, _)| format!("did you mean \"{}\"?", v))
}
fn simple_edit_distance(a: &str, b: &str) -> usize {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
let len = a_bytes.len().max(b_bytes.len());
let min_len = a_bytes.len().min(b_bytes.len());
let mut dist = len - min_len;
for i in 0..min_len {
if a_bytes[i] != b_bytes[i] {
dist += 1;
}
}
dist
}
pub fn humanize_relation(relation: &str) -> String {
let stripped = relation.rsplit(':').next().unwrap_or(relation);
stripped.replace('_', " ")
}
pub fn humanize_time(created_ms: i64, now_secs: i64) -> String {
let created_secs = if created_ms > 100_000_000_000 { created_ms / 1000 } else { created_ms };
let age = now_secs.saturating_sub(created_secs);
const DAY: i64 = 86_400;
if age < 3600 {
format!("{}m ago", (age / 60).max(0))
} else if age < DAY {
format!("{}h ago", age / 3600)
} else if age < 7 * DAY {
let d = age / DAY;
if d <= 1 { "yesterday".to_string() } else { format!("{d}d ago") }
} else if age < 30 * DAY {
format!("{}w ago", age / (7 * DAY))
} else if age < 365 * DAY {
format!("{}mo ago", age / (30 * DAY))
} else {
format!("{}y ago", age / (365 * DAY))
}
}
fn project_content(grain: &CalGrainResult) -> ResolvedValue {
let f = &grain.fields;
let get = |k: &str| f.get(k).and_then(|v| v.as_str()).map(str::to_string);
let text = match grain.grain_type.as_str() {
"fact" => {
let rel = get("relation").unwrap_or_default();
let obj = get("object").unwrap_or_default();
let humanized = humanize_relation(&rel);
match (humanized.is_empty(), obj.is_empty()) {
(true, _) => obj,
(_, true) => humanized,
_ => format!("{humanized} {obj}"),
}
}
"event" => get("content").unwrap_or_default(),
"goal" | "tool" | "observation" | "consensus" => get("object").unwrap_or_default(),
"reasoning" => get("conclusion").unwrap_or_default(),
"state" => get("plan").or_else(|| get("state_value")).unwrap_or_default(),
"workflow" => match f.get("nodes").and_then(|v| v.as_array()) {
Some(nodes) => nodes
.iter()
.filter_map(|n| n.as_str())
.collect::<Vec<_>>()
.join(" -> "),
None => String::new(),
},
"consent" => get("purpose").unwrap_or_default(),
"skill" => get("description").unwrap_or_default(),
"recommendation" => f
.get("summary")
.and_then(|s| s.get("args"))
.and_then(|a| a.as_object())
.map(|o| {
let mut vs: Vec<String> = o
.iter()
.map(|(k, v)| match v {
serde_json::Value::String(t) => format!("{k}={t}"),
other => format!("{k}={other}"),
})
.collect();
vs.sort();
vs.join(", ")
})
.or_else(|| get("target_ref"))
.unwrap_or_default(),
_ => get("content").or_else(|| get("object")).unwrap_or_default(),
};
if text.is_empty() { ResolvedValue::Null } else { ResolvedValue::Str(text) }
}
fn resolve_grain_var(field: &str, grain: &CalGrainResult, ctx: &RenderContext) -> ResolvedValue {
match field {
"content" => project_content(grain),
"type" => ResolvedValue::Str(grain.grain_type.clone()),
"hash" => ResolvedValue::Str(grain.hash.clone()),
"score" => ResolvedValue::Number(grain.score),
"humanized_relation" => match grain.fields.get("relation").and_then(|v| v.as_str()) {
Some(r) => ResolvedValue::Str(humanize_relation(r)),
None => ResolvedValue::Null,
},
"relative_time" => match grain.fields.get("created_at").and_then(|v| v.as_i64()) {
Some(ms) => ResolvedValue::Str(humanize_time(ms, ctx.now_secs)),
None => ResolvedValue::Null,
},
"is_full" => ResolvedValue::Bool(ctx.tier == DisclosureTier::Full),
"is_summary" => ResolvedValue::Bool(ctx.tier == DisclosureTier::Summary),
_ => {
if !ALLOWED_FIELDS.contains(&field) {
return ResolvedValue::Null;
}
resolve_from_fields(&grain.fields, field)
}
}
}
fn resolve_variable(
name: &str,
grain: Option<&CalGrainResult>,
index: Option<usize>,
ctx: &RenderContext,
env: &RenderEnv<'_>,
) -> ResolvedValue {
if let Some(var_name) = name.strip_prefix('$') {
return match ctx.user_vars.get(var_name) {
Some(val) => ResolvedValue::Str(val.clone()),
None => ResolvedValue::Null,
};
}
match name {
"disclosure.level" => {
return ResolvedValue::Str(
match ctx.tier {
DisclosureTier::Summary => "summary",
DisclosureTier::Headlines => "headlines",
DisclosureTier::Full => "full",
}
.to_string(),
)
}
"timestamp" => return ResolvedValue::Integer(ctx.now_secs),
_ => {}
}
if let Some(field) = name.strip_prefix("assembly.") {
let Some(a) = env.assembly else {
return ResolvedValue::Null;
};
return match field {
"name" => ResolvedValue::Str(a.name.clone()),
"intent" => ResolvedValue::Str(a.intent.clone()),
"source_count" => ResolvedValue::Integer(a.source_count as i64),
"grain_count" => ResolvedValue::Integer(a.grain_count as i64),
_ => ResolvedValue::Null,
};
}
if let Some(field) = name.strip_prefix("budget.") {
let Some(a) = env.assembly else {
return ResolvedValue::Null;
};
return match field {
"total" => ResolvedValue::Integer(a.budget_total as i64),
"used" => ResolvedValue::Integer(a.budget_used as i64),
"remaining" => ResolvedValue::Integer(a.budget_remaining() as i64),
"unit" => ResolvedValue::Str(a.budget_unit.clone()),
"utilization" => ResolvedValue::Number(a.budget_utilization()),
_ => ResolvedValue::Null,
};
}
if let Some(field) = name.strip_prefix("source.") {
let Some((src, idx)) = env.source else {
return ResolvedValue::Null;
};
return match field {
"label" => ResolvedValue::Str(src.label.to_string()),
"index" => ResolvedValue::Integer(idx as i64),
"priority" => ResolvedValue::Integer(src.priority as i64),
"grain_count" => ResolvedValue::Integer(src.grains.len() as i64),
"tokens_used" => ResolvedValue::Integer(src.tokens_used as i64),
"truncated" => ResolvedValue::Bool(src.truncated),
_ => ResolvedValue::Null,
};
}
if let Some(field) = name.strip_prefix("grain.") {
return match grain {
Some(g) => resolve_grain_var(field, g, ctx),
None => ResolvedValue::Null,
};
}
match name {
"_index" => return ResolvedValue::Integer(index.unwrap_or(0) as i64),
"_count" => return ResolvedValue::Integer(ctx.total_count as i64),
"_first" => return ResolvedValue::Bool(index == Some(0)),
"_last" => return ResolvedValue::Bool(index.is_some_and(|i| i + 1 == ctx.total_count)),
"_now" => return ResolvedValue::Integer(ctx.now_secs),
_ => {}
}
let Some(grain) = grain else {
return ResolvedValue::Null;
};
match name {
"hash" => return ResolvedValue::Str(grain.hash.clone()),
"grain_type" => return ResolvedValue::Str(grain.grain_type.clone()),
"score" => return ResolvedValue::Number(grain.score),
_ => {}
}
if !ALLOWED_FIELDS.contains(&name) {
return ResolvedValue::Null;
}
resolve_from_fields(&grain.fields, name)
}
fn resolve_from_fields(fields: &serde_json::Value, name: &str) -> ResolvedValue {
let Some(val) = fields.get(name) else {
return ResolvedValue::Null;
};
match val {
serde_json::Value::String(s) => ResolvedValue::Str(s.clone()),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
ResolvedValue::Integer(i)
} else if let Some(f) = n.as_f64() {
ResolvedValue::Number(f)
} else {
ResolvedValue::Str(n.to_string())
}
}
serde_json::Value::Bool(b) => ResolvedValue::Bool(*b),
serde_json::Value::Array(arr) => {
let strings: Vec<String> = arr
.iter()
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect();
ResolvedValue::Array(strings)
}
serde_json::Value::Null => ResolvedValue::Null,
serde_json::Value::Object(_) => {
ResolvedValue::Str(val.to_string())
}
}
}
pub fn apply_filter(
value: &ResolvedValue,
filter: &Filter,
ctx: &RenderContext,
) -> CalResult<ResolvedValue> {
match filter.name.as_str() {
"truncate" => {
let n: usize = filter
.arg
.as_ref()
.and_then(|a| a.parse().ok())
.unwrap_or(80);
let effective = effective_truncate(Some(n), ctx.tier);
let s = value.to_display();
if char_len(&s) > effective {
let truncated = truncate_at_char_boundary(&s, effective);
Ok(ResolvedValue::Str(format!("{}...", truncated)))
} else {
Ok(ResolvedValue::Str(s))
}
}
"relative" => match value {
ResolvedValue::Integer(epoch) => Ok(ResolvedValue::Str(
super::humanize::humanize_time(*epoch, ctx.now_secs),
)),
ResolvedValue::Number(n) => Ok(ResolvedValue::Str(super::humanize::humanize_time(
*n as i64,
ctx.now_secs,
))),
_ => Ok(ResolvedValue::Str(value.to_display())),
},
"humanize" => {
let s = value.to_display();
Ok(ResolvedValue::Str(super::humanize::humanize_relation(&s)))
}
"percent" => match value {
ResolvedValue::Number(n) => Ok(ResolvedValue::Str(format!("{}%", (*n * 100.0) as u32))),
ResolvedValue::Integer(n) => Ok(ResolvedValue::Str(format!("{}%", *n * 100))),
_ => Ok(ResolvedValue::Str(value.to_display())),
},
"uppercase" => Ok(ResolvedValue::Str(value.to_display().to_uppercase())),
"lowercase" => Ok(ResolvedValue::Str(value.to_display().to_lowercase())),
"json" => {
let s = match value {
ResolvedValue::Str(s) => {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
ResolvedValue::Number(n) => format!("{}", n),
ResolvedValue::Integer(n) => format!("{}", n),
ResolvedValue::Bool(b) => format!("{}", b),
ResolvedValue::Array(a) => {
let items: Vec<String> = a
.iter()
.map(|s| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")))
.collect();
format!("[{}]", items.join(","))
}
ResolvedValue::Null => "null".to_string(),
};
Ok(ResolvedValue::Str(s))
}
"default" => {
if value.is_truthy() {
Ok(value.clone())
} else {
Ok(ResolvedValue::Str(filter.arg.clone().unwrap_or_default()))
}
}
"join" => {
let sep = filter.arg.as_deref().unwrap_or(", ");
match value {
ResolvedValue::Array(items) => Ok(ResolvedValue::Str(items.join(sep))),
_ => Ok(ResolvedValue::Str(value.to_display())),
}
}
"date" => match value {
ResolvedValue::Integer(epoch) => {
let fmt = filter.arg.as_deref().unwrap_or("%Y-%m-%d");
Ok(ResolvedValue::Str(format_epoch(*epoch, fmt)))
}
ResolvedValue::Number(n) => {
let fmt = filter.arg.as_deref().unwrap_or("%Y-%m-%d");
Ok(ResolvedValue::Str(format_epoch(*n as i64, fmt)))
}
_ => Ok(ResolvedValue::Str(value.to_display())),
},
unknown => Err(CalError::TemplateUnknownFilter {
name: unknown.to_string(),
span: None,
}),
}
}
pub fn format_epoch(epoch_secs: i64, fmt: &str) -> String {
if epoch_secs < 0 {
return "<invalid date>".to_string();
}
if fmt.len() > 256 {
return "<format too long>".to_string();
}
let day_secs = (epoch_secs % 86400) as u32;
let h = day_secs / 3600;
let m = (day_secs % 3600) / 60;
let s = day_secs % 60;
let total_days = (epoch_secs / 86400) as i32;
let z = total_days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i32 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if mo <= 2 { y + 1 } else { y };
let mut result = String::with_capacity(fmt.len() + 16);
let fchars: Vec<char> = fmt.chars().collect();
let mut i = 0;
while i < fchars.len() {
if fchars[i] == '%' && i + 1 < fchars.len() {
match fchars[i + 1] {
'Y' => {
result.push_str(&format!("{:04}", year));
i += 2;
}
'm' => {
result.push_str(&format!("{:02}", mo));
i += 2;
}
'd' => {
result.push_str(&format!("{:02}", d));
i += 2;
}
'H' => {
result.push_str(&format!("{:02}", h));
i += 2;
}
'M' => {
result.push_str(&format!("{:02}", m));
i += 2;
}
'S' => {
result.push_str(&format!("{:02}", s));
i += 2;
}
_ => {
result.push('%');
result.push(fchars[i + 1]);
i += 2;
}
}
} else {
result.push(fchars[i]);
i += 1;
}
}
result
}
fn char_len(s: &str) -> usize {
s.chars().count()
}
fn truncate_at_char_boundary(s: &str, max_chars: usize) -> &str {
if max_chars == 0 {
return "";
}
match s.char_indices().nth(max_chars) {
Some((idx, _)) => &s[..idx],
None => s,
}
}
fn truncate_str(s: &str, max_chars: usize) -> String {
if char_len(s) > max_chars {
format!("{}...", truncate_at_char_boundary(s, max_chars))
} else {
s.to_string()
}
}
pub fn select_tier(budget_tokens: u32, grain_count: usize) -> DisclosureTier {
if grain_count == 0 {
return DisclosureTier::Full;
}
let clamped_count = grain_count.min(u32::MAX as usize) as u32;
if clamped_count == 0 {
return DisclosureTier::Summary;
}
let tokens_per_grain = budget_tokens / clamped_count;
if tokens_per_grain >= 200 {
DisclosureTier::Full
} else if tokens_per_grain >= 80 {
DisclosureTier::Headlines
} else {
DisclosureTier::Summary
}
}
fn effective_truncate(explicit_limit: Option<usize>, tier: DisclosureTier) -> usize {
match explicit_limit {
Some(n) => match tier {
DisclosureTier::Summary => n.min(40),
DisclosureTier::Headlines => n.min(80),
DisclosureTier::Full => n,
},
None => match tier {
DisclosureTier::Summary => 40,
DisclosureTier::Headlines => 80,
DisclosureTier::Full => usize::MAX,
},
}
}
pub fn render(
template: &Template,
grains: &[CalGrainResult],
ctx: &RenderContext,
) -> CalResult<String> {
render_with_limit(template, grains, ctx, MAX_RENDER_OUTPUT_SIZE)
}
pub fn render_with_limit(
template: &Template,
grains: &[CalGrainResult],
ctx: &RenderContext,
max_output_size: usize,
) -> CalResult<String> {
if let Some(sections) = template.sections() {
return render_sectioned(
sections,
&[RenderSource::single(grains)],
ctx,
None,
max_output_size,
);
}
let mut output = String::with_capacity(grains.len().min(1024) * 128);
render_nodes(
&template.nodes,
grains,
None,
ctx,
&RenderEnv::default(),
&mut output,
max_output_size,
)?;
Ok(output)
}
pub fn each_iteration_cap(template: &Template, grain_count: usize) -> Option<(usize, usize)> {
(grain_count > MAX_EACH_ITERATIONS && nodes_contain_each(template.nodes()))
.then_some((MAX_EACH_ITERATIONS, grain_count))
}
fn nodes_contain_each(nodes: &[TemplateNode]) -> bool {
nodes.iter().any(|n| match n {
TemplateNode::Each { .. } => true,
TemplateNode::Condition {
if_body, else_body, ..
} => nodes_contain_each(if_body) || nodes_contain_each(else_body),
TemplateNode::InvertedSection { body, .. }
| TemplateNode::GrainTypeBlock { body, .. } => nodes_contain_each(body),
_ => false,
})
}
pub fn render_sectioned(
sections: &TemplateSections,
sources: &[RenderSource<'_>],
ctx: &RenderContext,
assembly: Option<&AssemblyContext>,
max_output_size: usize,
) -> CalResult<String> {
let (primary, fallback) = match ctx.tier {
DisclosureTier::Full => (§ions.element, §ions.element_summary),
_ => (§ions.element_summary, §ions.element),
};
let element = primary.as_ref().or(fallback.as_ref());
let needs_all_grains = [
§ions.header,
§ions.element,
§ions.element_summary,
§ions.element_omit,
§ions.source_break,
§ions.footer,
]
.into_iter()
.flatten()
.any(|nodes| nodes_contain_each(nodes));
let all_grains: Vec<CalGrainResult> = if needs_all_grains {
sources.iter().flat_map(|s| s.grains.iter().cloned()).collect()
} else {
Vec::new()
};
let mut pieces: Vec<String> = Vec::new();
let mut emitted = 0usize;
let mut rendered_bytes = 0usize;
let render_one = |nodes: &[TemplateNode],
current: Option<(&CalGrainResult, usize)>,
env: &RenderEnv<'_>,
pieces: &mut Vec<String>,
rendered_bytes: &mut usize|
-> CalResult<()> {
let mut buf = String::new();
render_nodes(nodes, &all_grains, current, ctx, env, &mut buf, max_output_size)?;
*rendered_bytes += buf.len();
pieces.push(buf);
Ok(())
};
let outer = RenderEnv {
assembly,
source: None,
};
if let Some(header) = §ions.header {
render_one(header, None, &outer, &mut pieces, &mut rendered_bytes)?;
}
for (s_idx, source) in sources.iter().enumerate() {
if s_idx > 0 {
if let Some(brk) = §ions.source_break {
render_one(brk, None, &outer, &mut pieces, &mut rendered_bytes)?;
}
}
let env = RenderEnv {
assembly,
source: Some((source, s_idx)),
};
if let Some(element) = element {
for grain in source.grains {
render_one(element, Some((grain, emitted)), &env, &mut pieces, &mut rendered_bytes)?;
emitted += 1;
if rendered_bytes > max_output_size {
return Err(CalError::RenderOutputTooLarge {
size: rendered_bytes,
max: max_output_size,
span: None,
});
}
}
}
if let Some(omit) = §ions.element_omit {
for grain in source.omitted {
render_one(omit, Some((grain, emitted)), &env, &mut pieces, &mut rendered_bytes)?;
emitted += 1;
if rendered_bytes > max_output_size {
return Err(CalError::RenderOutputTooLarge {
size: rendered_bytes,
max: max_output_size,
span: None,
});
}
}
}
}
if let Some(footer) = §ions.footer {
render_one(footer, None, &outer, &mut pieces, &mut rendered_bytes)?;
}
let output = pieces.join("\n");
if output.len() > max_output_size {
return Err(CalError::RenderOutputTooLarge {
size: output.len(),
max: max_output_size,
span: None,
});
}
Ok(output)
}
fn render_nodes(
nodes: &[TemplateNode],
grains: &[CalGrainResult],
current_grain: Option<(&CalGrainResult, usize)>,
ctx: &RenderContext,
env: &RenderEnv<'_>,
output: &mut String,
max_output_size: usize,
) -> CalResult<()> {
for node in nodes {
match node {
TemplateNode::Text(s) => output.push_str(s),
TemplateNode::Variable { name, filters } => {
let grain = current_grain.map(|(g, _)| g);
let index = current_grain.map(|(_, i)| i);
let mut value = resolve_variable(name, grain, index, ctx, env);
for filter in filters {
value = apply_filter(&value, filter, ctx)?;
}
output.push_str(&value.to_display());
}
TemplateNode::Each { body, .. } => {
for (i, grain) in grains.iter().take(MAX_EACH_ITERATIONS).enumerate() {
render_nodes(body, grains, Some((grain, i)), ctx, env, output, max_output_size)?;
if output.len() > max_output_size {
return Err(CalError::RenderOutputTooLarge {
size: output.len(),
max: max_output_size,
span: None,
});
}
}
}
TemplateNode::Condition {
field,
if_body,
else_body,
} => {
let grain = current_grain.map(|(g, _)| g);
let index = current_grain.map(|(_, i)| i);
let value = resolve_variable(field, grain, index, ctx, env);
if value.is_truthy() {
render_nodes(if_body, grains, current_grain, ctx, env, output, max_output_size)?;
} else if !else_body.is_empty() {
render_nodes(
else_body,
grains,
current_grain,
ctx,
env,
output,
max_output_size,
)?;
}
}
TemplateNode::InvertedSection { field, body } => {
let grain = current_grain.map(|(g, _)| g);
let index = current_grain.map(|(_, i)| i);
let value = resolve_variable(field, grain, index, ctx, env);
if !value.is_truthy() {
render_nodes(body, grains, current_grain, ctx, env, output, max_output_size)?;
}
}
TemplateNode::GrainTypeBlock { grain_type, body } => {
if let Some((grain, _)) = current_grain {
if grain.grain_type == *grain_type {
render_nodes(body, grains, current_grain, ctx, env, output, max_output_size)?;
}
}
}
TemplateNode::Comment(_) => { }
}
}
Ok(())
}
fn field_str(grain: &CalGrainResult, name: &str) -> String {
match grain.fields.get(name) {
Some(serde_json::Value::String(s)) => s.clone(),
Some(v) => v.to_string(),
None => String::new(),
}
}
fn field_str_or(grain: &CalGrainResult, name: &str, default: &str) -> String {
let s = field_str(grain, name);
if s.is_empty() {
default.to_string()
} else {
s
}
}
fn field_f64(grain: &CalGrainResult, name: &str) -> f64 {
match grain.fields.get(name) {
Some(serde_json::Value::Number(n)) => n.as_f64().unwrap_or(0.0),
_ => 0.0,
}
}
fn field_i64(grain: &CalGrainResult, name: &str) -> i64 {
match grain.fields.get(name) {
Some(serde_json::Value::Number(n)) => n.as_i64().unwrap_or(0),
_ => 0,
}
}
fn field_bool(grain: &CalGrainResult, name: &str) -> bool {
match grain.fields.get(name) {
Some(serde_json::Value::Bool(b)) => *b,
_ => false,
}
}
fn format_confidence(conf: f64) -> String {
format!("{}%", (conf * 100.0) as u32)
}
pub fn default_grain_render(grain: &CalGrainResult, ctx: &RenderContext) -> String {
match grain.grain_type.as_str() {
"fact" => format!(
"{} {} {} ({})",
field_str(grain, "subject"),
super::humanize::humanize_relation(&field_str(grain, "relation")),
field_str(grain, "object"),
format_confidence(field_f64(grain, "confidence")),
),
"event" => format!(
"{} {} [{}]",
field_str(grain, "actor"),
field_str(grain, "action"),
super::humanize::humanize_time(field_i64(grain, "created_at"), ctx.now_secs),
),
"state" => format!(
"{}: {} ({})",
field_str(grain, "entity"),
field_str(grain, "state_value"),
field_str_or(grain, "validity", "current"),
),
"workflow" => {
let node_count = grain
.fields
.get("nodes")
.and_then(|v: &serde_json::Value| v.as_array())
.map(|a: &Vec<serde_json::Value>| a.len())
.unwrap_or(0);
let edge_count = grain
.fields
.get("edges")
.and_then(|v: &serde_json::Value| v.as_array())
.map(|a: &Vec<serde_json::Value>| a.len())
.unwrap_or(0);
format!(
"{} ({} nodes, {} edges, {})",
field_str(grain, "name"),
node_count,
edge_count,
field_str(grain, "status"),
)
}
"tool" => format!(
"{}({}) -> {}{}",
field_str(grain, "tool_name"),
truncate_str(&field_str(grain, "input"), 40),
truncate_str(&field_str(grain, "content"), 60),
if field_bool(grain, "is_error") {
" [ERROR]"
} else {
""
},
),
"observation" => format!(
"{} observed {}: {}",
field_str(grain, "observer"),
field_str(grain, "observed"),
truncate_str(&field_str(grain, "sensory_data"), 60),
),
"goal" => format!(
"{} - {} ({})",
field_str(grain, "goal_state"),
field_str_or(grain, "assigned_to", "unassigned"),
field_str_or(grain, "priority", "normal"),
),
"reasoning" => format!(
"{} -> {} ({})",
truncate_str(&field_str(grain, "premises"), 60),
field_str(grain, "conclusion"),
format_confidence(field_f64(grain, "confidence")),
),
"consensus" => format!(
"{}: {}",
field_str(grain, "participants"),
field_str(grain, "agreement_level"),
),
"consent" => format!(
"{}: {} [{}]",
field_str(grain, "consenter"),
field_str(grain, "scope"),
field_str_or(grain, "expires_at", "no expiry"),
),
"skill" => format!(
"{}: {} [{}] ({})",
field_str(grain, "name"),
truncate_str(&field_str(grain, "description"), 60),
field_str_or(grain, "domain", "general"),
format_confidence(field_f64(grain, "proficiency")),
),
"recommendation" => format!(
"[{}] {} -> {}",
field_str_or(grain, "severity", "info"),
truncate_str(&field_str(grain, "dedup_key"), 12),
field_str(grain, "target_ref"),
),
_ => format!("{}: {}", grain.grain_type, grain.hash),
}
}
#[derive(Debug, Clone)]
pub struct TemplateEntry {
pub template: Template,
pub builtin: bool,
pub parent: Option<String>,
pub description: String,
pub grain_types: Vec<String>,
pub last_run_at: Option<u64>,
pub updated_at: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TemplateListEntry {
pub name: String,
pub description: String,
pub builtin: bool,
pub parent: Option<String>,
pub grain_types: Vec<String>,
pub source: String,
pub last_run_at: Option<u64>,
pub updated_at: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TemplateInfo {
pub name: String,
pub description: String,
pub builtin: bool,
pub parent: Option<String>,
pub source: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PersistedTemplate {
pub source: String,
pub description: String,
pub parent: Option<String>,
pub grain_types: Vec<String>,
#[serde(default)]
pub last_run_at: Option<u64>,
#[serde(default)]
pub updated_at: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct TemplateRegistry {
templates: HashMap<String, TemplateEntry>,
}
impl Default for TemplateRegistry {
fn default() -> Self {
Self::new()
}
}
impl TemplateRegistry {
pub fn new() -> Self {
let mut reg = Self {
templates: HashMap::new(),
};
reg.load_builtins();
reg
}
pub fn register(
&mut self,
name: &str,
source: &str,
description: &str,
parent: Option<&str>,
) -> CalResult<()> {
if !is_valid_template_name(name) {
return Err(CalError::TemplateInvalidName {
name: name.to_string(),
span: None,
});
}
if self.templates.get(name).is_some_and(|e| e.builtin) {
return Err(CalError::TemplateBuiltinImmutable {
name: name.to_string(),
span: None,
});
}
if let Some(p) = parent {
if p.eq_ignore_ascii_case("data") {
return Err(CalError::CannotExtendData {
name: name.to_string(),
span: None,
});
}
if !self.templates.contains_key(p) {
return Err(CalError::TemplateParentNotFound {
name: name.to_string(),
parent: p.to_string(),
span: None,
});
}
if self.templates[p].parent.is_some() {
return Err(CalError::TemplateInheritanceDepth {
name: name.to_string(),
span: None,
});
}
}
if !self.templates.contains_key(name) {
let custom = self.templates.values().filter(|e| !e.builtin).count();
if custom >= MAX_TEMPLATES {
return Err(CalError::TooManyTemplates {
count: custom,
max: MAX_TEMPLATES,
span: None,
});
}
}
let template = parse_template_any(source)?;
self.templates.insert(
name.to_string(),
TemplateEntry {
template,
builtin: false,
parent: parent.map(|s| s.to_string()),
description: description.to_string(),
grain_types: Vec::new(),
last_run_at: None,
updated_at: Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
),
},
);
Ok(())
}
pub fn get(&self, name: &str) -> Option<&TemplateEntry> {
self.templates.get(name)
}
pub fn set_grain_types(&mut self, name: &str, grain_types: &[String]) {
if let Some(entry) = self.templates.get_mut(name) {
entry.grain_types = grain_types.to_vec();
}
}
pub fn list(&self) -> Vec<TemplateListEntry> {
let mut entries: Vec<TemplateListEntry> = self
.templates
.iter()
.map(|(name, entry)| TemplateListEntry {
name: name.clone(),
description: entry.description.clone(),
builtin: entry.builtin,
parent: entry.parent.clone(),
grain_types: entry.grain_types.clone(),
source: entry.template.source().to_string(),
last_run_at: entry.last_run_at,
updated_at: entry.updated_at,
})
.collect();
entries.sort_by_key(|b| std::cmp::Reverse(b.updated_at.unwrap_or(0)));
entries
}
pub fn restore_timestamps(
&mut self,
name: &str,
last_run_at: Option<u64>,
updated_at: Option<u64>,
) {
if let Some(entry) = self.templates.get_mut(name) {
if last_run_at.is_some() {
entry.last_run_at = last_run_at;
}
if updated_at.is_some() {
entry.updated_at = updated_at;
}
}
}
pub fn record_run(&mut self, name: &str) {
if let Some(entry) = self.templates.get_mut(name) {
entry.last_run_at = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
);
}
}
pub fn delete(&mut self, name: &str) -> CalResult<()> {
match self.templates.get(name) {
None => Err(CalError::TemplateNotFound {
name: name.to_string(),
span: None,
}),
Some(entry) if entry.builtin => Err(CalError::TemplateBuiltinImmutable {
name: name.to_string(),
span: None,
}),
_ => {
self.templates.remove(name);
Ok(())
}
}
}
fn load_builtins(&mut self) {
self.insert_builtin_sectioned(
"structured",
"ELEMENT {\n\
<{{grain.type}} subject=\"{{grain.subject}}\"{{#grain.confidence}} \
confidence=\"{{grain.confidence}}\"{{/grain.confidence}}>\
{{grain.content}}</{{grain.type}}>\n\
}\n\
ELEMENT_SUMMARY {\n\
<{{grain.type}} subject=\"{{grain.subject}}\">{{grain.content}}</{{grain.type}}>\n\
}\n",
"OMS §10.1 structured/sml preset — one SML element per grain",
);
self.insert_builtin_sectioned(
"readable",
"ELEMENT {\n\
- **{{grain.subject}}** {{grain.content}}{{#grain.confidence}} \
_({{grain.confidence | percent}})_{{/grain.confidence}}\n\
}\n\
ELEMENT_SUMMARY {\n\
- {{grain.subject}} {{grain.content}}\n\
}\n",
"OMS §10.1 readable/markdown preset — the default parent",
);
self.insert_builtin_sectioned(
"compact",
"ELEMENT {\n\
{{grain.subject}} {{grain.content}}\n\
}\n\
ELEMENT_SUMMARY {\n\
{{grain.content}}\n\
}\n",
"OMS §10.1 compact/text preset — minimal, token-efficient",
);
}
fn insert_builtin_sectioned(&mut self, name: &str, source: &str, description: &str) {
debug_assert!(
!FORMAT_ARM_NAMES.contains(&name),
"built-in template \"{name}\" would shadow the FORMAT {name} arm"
);
let template = match parse_template_any(source) {
Ok(t) => t,
Err(e) => {
#[cfg(debug_assertions)]
panic!("built-in template \"{}\" failed to parse: {}", name, e);
#[cfg(not(debug_assertions))]
{
let _ = e;
return;
}
}
};
debug_assert!(
template.is_sectioned(),
"built-in preset \"{name}\" must parse as sectioned"
);
self.templates.insert(
name.to_string(),
TemplateEntry {
template,
builtin: true,
parent: None,
description: description.to_string(),
grain_types: Vec::new(),
last_run_at: None,
updated_at: None,
},
);
}
}
pub fn resolve_inheritance(entry: &TemplateEntry, registry: &TemplateRegistry) -> Template {
let default_parent = entry.parent.is_none()
&& entry.template.is_sectioned()
&& !entry.builtin;
if default_parent {
if let Some(readable) = registry.get("readable") {
return merge_templates(&readable.template, &entry.template);
}
}
let Some(ref parent_name) = entry.parent else {
return entry.template.clone();
};
let Some(parent_entry) = registry.get(parent_name) else {
return entry.template.clone();
};
merge_templates(&parent_entry.template, &entry.template)
}
pub fn merge_templates(parent: &Template, child: &Template) -> Template {
if let Some(child_sections) = child.sections() {
let mut merged = child_sections.clone();
if let Some(parent_sections) = parent.sections() {
merged.inherit_from(parent_sections);
}
return Template::from_sections(child.source.clone(), merged);
}
let mut merged_nodes = parent.nodes.clone();
for child_node in &child.nodes {
if let TemplateNode::GrainTypeBlock { grain_type, body } = child_node {
let mut replaced = false;
for node in merged_nodes.iter_mut() {
match node {
TemplateNode::GrainTypeBlock {
grain_type: gt,
body: parent_body,
} if gt == grain_type => {
*parent_body = body.clone();
replaced = true;
break;
}
TemplateNode::Each {
body: each_body, ..
} => {
for inner in each_body.iter_mut() {
if let TemplateNode::GrainTypeBlock {
grain_type: gt,
body: parent_body,
} = inner
{
if gt == grain_type {
*parent_body = body.clone();
replaced = true;
break;
}
}
}
if replaced {
break;
}
}
_ => {}
}
}
if !replaced {
merged_nodes.push(child_node.clone());
}
}
}
Template {
source: child.source.clone(),
nodes: merged_nodes,
sections: None,
}
}
pub fn apply_format(
grains: &[CalGrainResult],
template: &Template,
ctx: &RenderContext,
) -> CalResult<String> {
render(template, grains, ctx)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_fact(subject: &str, relation: &str, object: &str, confidence: f64) -> CalGrainResult {
CalGrainResult {
hash: "abc123".to_string(),
grain_type: "fact".to_string(),
score: 0.95,
fields: json!({
"subject": subject,
"relation": relation,
"object": object,
"confidence": confidence,
"created_at": 1700000000_i64,
"namespace": "default",
"user_id": "user1",
}),
score_breakdown: None,
explanation: None,
relative_time: None,
is_deterministic: false,
contested_by: None,
}
}
fn make_tool(tool: &str, input: &str, content: &str, is_error: bool) -> CalGrainResult {
CalGrainResult {
hash: "def456".to_string(),
grain_type: "tool".to_string(),
score: 0.90,
fields: json!({
"tool_name": tool,
"input": input,
"content": content,
"is_error": is_error,
"created_at": 1700000000_i64,
"duration_ms": 150,
}),
score_breakdown: None,
explanation: None,
relative_time: None,
is_deterministic: false,
contested_by: None,
}
}
fn make_event(actor: &str, action: &str) -> CalGrainResult {
CalGrainResult {
hash: "ghi789".to_string(),
grain_type: "event".to_string(),
score: 0.85,
fields: json!({
"actor": actor,
"action": action,
"created_at": 1700000000_i64,
}),
score_breakdown: None,
explanation: None,
relative_time: None,
is_deterministic: false,
contested_by: None,
}
}
fn make_goal(title: &str, goal_state: &str) -> CalGrainResult {
CalGrainResult {
hash: "jkl012".to_string(),
grain_type: "goal".to_string(),
score: 0.80,
fields: json!({
"title": title,
"goal_state": goal_state,
"priority": "high",
"assigned_to": "john",
}),
score_breakdown: None,
explanation: None,
relative_time: None,
is_deterministic: false,
contested_by: None,
}
}
#[test]
fn test_humanize_relation_per_spec() {
assert_eq!(humanize_relation("mg:prefers"), "prefers");
assert_eq!(humanize_relation("works_at"), "works at");
assert_eq!(humanize_relation("acme:similar_to"), "similar to");
assert_eq!(humanize_relation("knows"), "knows");
}
#[test]
fn test_humanize_time_buckets_per_spec() {
let now = 1_000_000_000i64;
let ago = |secs: i64| humanize_time((now - secs) * 1000, now);
assert_eq!(ago(23 * 60), "23m ago");
assert_eq!(ago(3 * 3600), "3h ago");
assert_eq!(ago(86_400), "yesterday");
assert_eq!(ago(2 * 86_400), "2d ago");
assert_eq!(ago(14 * 86_400), "2w ago");
assert_eq!(ago(45 * 86_400), "1mo ago");
assert_eq!(ago(400 * 86_400), "1y ago");
}
#[test]
fn test_humanize_time_granularity_only_coarsens() {
let now = 1_000_000_000i64;
let unit = |secs: i64| {
let s = humanize_time((now - secs) * 1000, now);
s.trim_end_matches(" ago")
.trim_start_matches(|c: char| c.is_ascii_digit())
.to_string()
};
let order = ["m", "h", "d", "w", "mo", "y"];
let rank = |u: &str| order.iter().position(|o| *o == u);
let mut last = 0usize;
for days in [0, 1, 3, 10, 29, 31, 200, 400, 1000] {
let u = unit(days * 86_400 + 1);
let Some(r) = rank(&u) else { continue };
assert!(r >= last, "granularity went backwards at {days}d: {u:?}");
last = r;
}
}
#[test]
fn test_content_projection_per_grain_type() {
let mk = |ty: &str, fields: serde_json::Value| CalGrainResult {
hash: "h".into(),
grain_type: ty.into(),
fields,
score: 0.0,
is_deterministic: true,
contested_by: None,
explanation: None,
score_breakdown: None,
relative_time: None,
};
let content = |g: &CalGrainResult| match project_content(g) {
ResolvedValue::Str(s) => s,
_ => String::new(),
};
assert_eq!(
content(&mk("fact", serde_json::json!({"relation": "works_at", "object": "acme"}))),
"works at acme"
);
assert_eq!(
content(&mk("event", serde_json::json!({"content": "asked about pricing"}))),
"asked about pricing"
);
assert_eq!(
content(&mk("reasoning", serde_json::json!({"conclusion": "prioritise reliability"}))),
"prioritise reliability"
);
assert_eq!(
content(&mk("consent", serde_json::json!({"purpose": "access dashboards"}))),
"access dashboards"
);
assert_eq!(
content(&mk("workflow", serde_json::json!({"nodes": ["a", "b", "c"]}))),
"a -> b -> c"
);
}
#[test]
fn test_grain_namespace_is_still_a_closed_set() {
assert!(validate_variable_name("grain.subject", None).is_ok());
assert!(validate_variable_name("grain.content", None).is_ok());
assert!(validate_variable_name("grain.relative_time", None).is_ok());
assert!(validate_variable_name("disclosure.level", None).is_ok());
assert!(validate_variable_name("grain.not_a_field", None).is_err());
assert!(validate_variable_name("nonsense.thing", None).is_err());
}
fn test_ctx() -> RenderContext {
RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: HashMap::new(),
}
}
#[test]
fn test_valid_template_names() {
assert!(is_valid_template_name("triples"));
assert!(is_valid_template_name("a"));
assert!(is_valid_template_name("my_template"));
assert!(is_valid_template_name("template123"));
assert!(is_valid_template_name("a_b_c_d"));
assert!(is_valid_template_name("HasUpperCase"));
assert!(is_valid_template_name("has space"));
assert!(is_valid_template_name("has-hyphen"));
assert!(is_valid_template_name("Customer Support Context"));
assert!(is_valid_template_name("My Template-1"));
assert!(is_valid_template_name(&"a".repeat(64)));
}
#[test]
fn test_invalid_template_names() {
assert!(!is_valid_template_name(""));
assert!(!is_valid_template_name("1starts_with_digit"));
assert!(!is_valid_template_name("_starts_with_underscore"));
assert!(!is_valid_template_name("-starts-with-hyphen"));
assert!(!is_valid_template_name("has.dot"));
assert!(!is_valid_template_name("trailing space "));
assert!(!is_valid_template_name(" leading space"));
assert!(!is_valid_template_name("double space"));
assert!(!is_valid_template_name(&"a".repeat(65)));
}
#[test]
fn test_parse_simple_text() {
let t = parse_template("Hello, world!").unwrap();
assert_eq!(t.nodes().len(), 1);
assert_eq!(t.nodes()[0], TemplateNode::Text("Hello, world!".into()));
}
#[test]
fn test_parse_variable() {
let t = parse_template("{{subject}}").unwrap();
assert_eq!(t.nodes().len(), 1);
match &t.nodes()[0] {
TemplateNode::Variable { name, filters } => {
assert_eq!(name, "subject");
assert!(filters.is_empty());
}
_ => panic!("expected Variable"),
}
}
#[test]
fn test_parse_variable_with_filter() {
let t = parse_template("{{subject | uppercase}}").unwrap();
match &t.nodes()[0] {
TemplateNode::Variable { name, filters } => {
assert_eq!(name, "subject");
assert_eq!(filters.len(), 1);
assert_eq!(filters[0].name, "uppercase");
assert!(filters[0].arg.is_none());
}
_ => panic!("expected Variable"),
}
}
#[test]
fn test_parse_variable_with_filter_arg() {
let t = parse_template("{{content | truncate 80}}").unwrap();
match &t.nodes()[0] {
TemplateNode::Variable { name, filters } => {
assert_eq!(name, "content");
assert_eq!(filters.len(), 1);
assert_eq!(filters[0].name, "truncate");
assert_eq!(filters[0].arg, Some("80".to_string()));
}
_ => panic!("expected Variable"),
}
}
#[test]
fn test_parse_multiple_filters() {
let t = parse_template("{{relation | humanize | uppercase}}").unwrap();
match &t.nodes()[0] {
TemplateNode::Variable { name, filters } => {
assert_eq!(name, "relation");
assert_eq!(filters.len(), 2);
assert_eq!(filters[0].name, "humanize");
assert_eq!(filters[1].name, "uppercase");
}
_ => panic!("expected Variable"),
}
}
#[test]
fn test_parse_each_block() {
let t = parse_template("{{#each grains}}{{subject}}{{/each}}").unwrap();
assert_eq!(t.nodes().len(), 1);
match &t.nodes()[0] {
TemplateNode::Each { collection, body } => {
assert_eq!(collection, "grains");
assert_eq!(body.len(), 1);
}
_ => panic!("expected Each"),
}
}
#[test]
fn test_parse_condition() {
let t = parse_template("{{#if confidence}}yes{{/if}}").unwrap();
match &t.nodes()[0] {
TemplateNode::Condition {
field,
if_body,
else_body,
} => {
assert_eq!(field, "confidence");
assert_eq!(if_body.len(), 1);
assert!(else_body.is_empty());
}
_ => panic!("expected Condition"),
}
}
#[test]
fn test_parse_condition_with_else() {
let t = parse_template("{{#if confidence}}yes{{else}}no{{/if}}").unwrap();
match &t.nodes()[0] {
TemplateNode::Condition {
field,
if_body,
else_body,
} => {
assert_eq!(field, "confidence");
assert_eq!(if_body.len(), 1);
assert_eq!(else_body.len(), 1);
}
_ => panic!("expected Condition"),
}
}
#[test]
fn test_parse_inverted_section() {
let t = parse_template("{{^summary}}no summary{{/summary}}").unwrap();
match &t.nodes()[0] {
TemplateNode::InvertedSection { field, body } => {
assert_eq!(field, "summary");
assert_eq!(body.len(), 1);
}
_ => panic!("expected InvertedSection"),
}
}
#[test]
fn test_parse_grain_type_block() {
let t = parse_template("{{#grain_type \"fact\"}}{{subject}}{{/grain_type}}").unwrap();
match &t.nodes()[0] {
TemplateNode::GrainTypeBlock { grain_type, body } => {
assert_eq!(grain_type, "fact");
assert_eq!(body.len(), 1);
}
_ => panic!("expected GrainTypeBlock"),
}
}
#[test]
fn test_parse_comment() {
let t = parse_template("{{! this is a comment }}hello").unwrap();
assert_eq!(t.nodes().len(), 2);
match &t.nodes()[0] {
TemplateNode::Comment(text) => assert_eq!(text, "this is a comment"),
_ => panic!("expected Comment"),
}
}
#[test]
fn test_parse_nested_each_rejected() {
let result = parse_template("{{#each grains}}{{#each grains}}{{/each}}{{/each}}");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E041");
}
#[test]
fn test_parse_unknown_variable_rejected() {
let result = parse_template("{{unknown_variable}}");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E042");
}
#[test]
fn test_parse_unknown_filter_rejected() {
let result = parse_template("{{subject | bogus_filter}}");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E043");
}
#[test]
fn test_parse_template_too_large() {
let huge = "x".repeat(MAX_TEMPLATE_SIZE + 1);
let result = parse_template(&huge);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E040");
}
#[test]
fn test_parse_truncate_out_of_range() {
let result = parse_template("{{subject | truncate 0}}");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E049");
let result2 = parse_template("{{subject | truncate 200000}}");
assert!(result2.is_err());
}
#[test]
fn test_parse_unclosed_tag() {
let result = parse_template("{{subject");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E049");
}
#[test]
fn test_parse_unclosed_section() {
let result = parse_template("{{#if confidence}}yes");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E049");
}
#[test]
fn test_resolved_value_truthiness() {
assert!(ResolvedValue::Str("hello".into()).is_truthy());
assert!(!ResolvedValue::Str("".into()).is_truthy());
assert!(ResolvedValue::Number(1.0).is_truthy());
assert!(!ResolvedValue::Number(0.0).is_truthy());
assert!(ResolvedValue::Integer(1).is_truthy());
assert!(!ResolvedValue::Integer(0).is_truthy());
assert!(ResolvedValue::Bool(true).is_truthy());
assert!(!ResolvedValue::Bool(false).is_truthy());
assert!(ResolvedValue::Array(vec!["a".into()]).is_truthy());
assert!(!ResolvedValue::Array(vec![]).is_truthy());
assert!(!ResolvedValue::Null.is_truthy());
}
#[test]
#[allow(clippy::approx_constant)]
fn test_resolved_value_display() {
assert_eq!(ResolvedValue::Str("hello".into()).to_display(), "hello");
assert_eq!(ResolvedValue::Number(3.14).to_display(), "3.14");
assert_eq!(ResolvedValue::Number(42.0).to_display(), "42");
assert_eq!(ResolvedValue::Integer(42).to_display(), "42");
assert_eq!(ResolvedValue::Bool(true).to_display(), "true");
assert_eq!(
ResolvedValue::Array(vec!["a".into(), "b".into()]).to_display(),
"a, b"
);
assert_eq!(ResolvedValue::Null.to_display(), "");
}
#[test]
fn test_resolve_metadata_variables() {
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 5,
user_vars: HashMap::new(),
};
let grain = make_fact("john", "mg:likes", "coffee", 0.94);
assert_eq!(
resolve_variable("_index", Some(&grain), Some(2), &ctx, &RenderEnv::default()).to_display(),
"2"
);
assert_eq!(
resolve_variable("_count", Some(&grain), Some(0), &ctx, &RenderEnv::default()).to_display(),
"5"
);
assert!(resolve_variable("_first", Some(&grain), Some(0), &ctx, &RenderEnv::default()).is_truthy());
assert!(!resolve_variable("_first", Some(&grain), Some(1), &ctx, &RenderEnv::default()).is_truthy());
assert!(resolve_variable("_last", Some(&grain), Some(4), &ctx, &RenderEnv::default()).is_truthy());
assert!(!resolve_variable("_last", Some(&grain), Some(3), &ctx, &RenderEnv::default()).is_truthy());
}
#[test]
fn test_resolve_direct_fields() {
let grain = make_fact("john", "mg:likes", "coffee", 0.94);
let ctx = test_ctx();
assert_eq!(
resolve_variable("hash", Some(&grain), None, &ctx, &RenderEnv::default()).to_display(),
"abc123"
);
assert_eq!(
resolve_variable("grain_type", Some(&grain), None, &ctx, &RenderEnv::default()).to_display(),
"fact"
);
assert_eq!(
resolve_variable("score", Some(&grain), None, &ctx, &RenderEnv::default()).to_display(),
"0.95"
);
}
#[test]
fn test_resolve_grain_fields() {
let grain = make_fact("john", "mg:likes", "coffee", 0.94);
let ctx = test_ctx();
assert_eq!(
resolve_variable("subject", Some(&grain), None, &ctx, &RenderEnv::default()).to_display(),
"john"
);
assert_eq!(
resolve_variable("relation", Some(&grain), None, &ctx, &RenderEnv::default()).to_display(),
"mg:likes"
);
assert_eq!(
resolve_variable("object", Some(&grain), None, &ctx, &RenderEnv::default()).to_display(),
"coffee"
);
}
#[test]
fn test_resolve_disallowed_field_returns_null() {
let grain = make_fact("john", "mg:likes", "coffee", 0.94);
let ctx = test_ctx();
assert!(!resolve_variable("secret_internal_field", Some(&grain), None, &ctx, &RenderEnv::default()).is_truthy());
}
#[test]
fn test_filter_truncate() {
let ctx = test_ctx();
let val = ResolvedValue::Str("Hello, world! This is a long string.".into());
let filter = Filter {
name: "truncate".into(),
arg: Some("13".into()),
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "Hello, world!...");
}
#[test]
fn test_filter_truncate_no_truncation_needed() {
let ctx = test_ctx();
let val = ResolvedValue::Str("short".into());
let filter = Filter {
name: "truncate".into(),
arg: Some("80".into()),
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "short");
}
#[test]
fn test_filter_relative() {
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: HashMap::new(),
};
let val = ResolvedValue::Integer(1700000000);
let filter = Filter {
name: "relative".into(),
arg: None,
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "1m ago");
}
#[test]
fn test_filter_humanize() {
let ctx = test_ctx();
let val = ResolvedValue::Str("mg:likes".into());
let filter = Filter {
name: "humanize".into(),
arg: None,
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "likes");
}
#[test]
fn test_filter_percent() {
let ctx = test_ctx();
let val = ResolvedValue::Number(0.94);
let filter = Filter {
name: "percent".into(),
arg: None,
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "94%");
}
#[test]
fn test_filter_uppercase_lowercase() {
let ctx = test_ctx();
let val = ResolvedValue::Str("Hello".into());
let upper = apply_filter(
&val,
&Filter {
name: "uppercase".into(),
arg: None,
},
&ctx,
)
.unwrap();
assert_eq!(upper.to_display(), "HELLO");
let lower = apply_filter(
&val,
&Filter {
name: "lowercase".into(),
arg: None,
},
&ctx,
)
.unwrap();
assert_eq!(lower.to_display(), "hello");
}
#[test]
fn test_filter_default() {
let ctx = test_ctx();
let truthy = ResolvedValue::Str("value".into());
let result = apply_filter(
&truthy,
&Filter {
name: "default".into(),
arg: Some("fallback".into()),
},
&ctx,
)
.unwrap();
assert_eq!(result.to_display(), "value");
let null = ResolvedValue::Null;
let result = apply_filter(
&null,
&Filter {
name: "default".into(),
arg: Some("fallback".into()),
},
&ctx,
)
.unwrap();
assert_eq!(result.to_display(), "fallback");
}
#[test]
fn test_filter_join() {
let ctx = test_ctx();
let val = ResolvedValue::Array(vec!["a".into(), "b".into(), "c".into()]);
let filter = Filter {
name: "join".into(),
arg: Some(", ".into()),
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "a, b, c");
}
#[test]
fn test_filter_date() {
let ctx = test_ctx();
let val = ResolvedValue::Integer(1700000000);
let filter = Filter {
name: "date".into(),
arg: Some("%Y-%m-%d".into()),
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "2023-11-14");
}
#[test]
fn test_filter_date_negative_epoch() {
let ctx = test_ctx();
let val = ResolvedValue::Integer(-100);
let filter = Filter {
name: "date".into(),
arg: None,
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "<invalid date>");
}
#[test]
fn test_filter_json() {
let ctx = test_ctx();
let val = ResolvedValue::Str("hello".into());
let filter = Filter {
name: "json".into(),
arg: None,
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "\"hello\"");
let bool_val = ResolvedValue::Bool(true);
let result = apply_filter(&bool_val, &filter, &ctx).unwrap();
assert_eq!(result.to_display(), "true");
}
#[test]
fn test_format_epoch_basic() {
assert_eq!(format_epoch(0, "%Y-%m-%d"), "1970-01-01");
assert_eq!(format_epoch(1700000000, "%Y-%m-%d"), "2023-11-14");
assert_eq!(
format_epoch(1700000000, "%Y-%m-%d %H:%M:%S"),
"2023-11-14 22:13:20"
);
}
#[test]
fn test_format_epoch_negative() {
assert_eq!(format_epoch(-1, "%Y-%m-%d"), "<invalid date>");
}
#[test]
fn test_format_epoch_long_format() {
let long_fmt = "%Y".repeat(200);
assert_eq!(format_epoch(0, &long_fmt), "<format too long>");
}
#[test]
fn test_render_simple_text() {
let t = parse_template("Hello!").unwrap();
let ctx = test_ctx();
let result = render(&t, &[], &ctx).unwrap();
assert_eq!(result, "Hello!");
}
#[test]
fn test_render_variable() {
let t = parse_template("{{#each grains}}{{subject}}{{/each}}").unwrap();
let grains = vec![make_fact("john", "mg:likes", "coffee", 0.94)];
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: grains.len(),
user_vars: HashMap::new(),
};
let result = render(&t, &grains, &ctx).unwrap();
assert_eq!(result, "john");
}
#[test]
fn test_render_each_with_filter() {
let t = parse_template(
"{{#each grains}}{{subject}} {{relation | humanize}} {{object}}\n{{/each}}",
)
.unwrap();
let grains = vec![
make_fact("john", "mg:likes", "coffee", 0.94),
make_fact("bob", "mg:knows", "john", 0.87),
];
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: grains.len(),
user_vars: HashMap::new(),
};
let result = render(&t, &grains, &ctx).unwrap();
assert_eq!(result, "john likes coffee\nbob knows john\n");
}
#[test]
fn test_render_condition_true() {
let t = parse_template("{{#each grains}}{{#if confidence}}yes{{/if}}{{/each}}").unwrap();
let grains = vec![make_fact("john", "mg:likes", "coffee", 0.94)];
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: HashMap::new(),
};
let result = render(&t, &grains, &ctx).unwrap();
assert_eq!(result, "yes");
}
#[test]
fn test_render_condition_false_with_else() {
let t = parse_template(
"{{#each grains}}{{#if summary}}has summary{{else}}no summary{{/if}}{{/each}}",
)
.unwrap();
let grains = vec![make_fact("john", "mg:likes", "coffee", 0.94)];
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: HashMap::new(),
};
let result = render(&t, &grains, &ctx).unwrap();
assert_eq!(result, "no summary");
}
#[test]
fn test_render_inverted_section() {
let t =
parse_template("{{#each grains}}{{^summary}}no summary{{/summary}}{{/each}}").unwrap();
let grains = vec![make_fact("john", "mg:likes", "coffee", 0.94)];
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: HashMap::new(),
};
let result = render(&t, &grains, &ctx).unwrap();
assert_eq!(result, "no summary");
}
#[test]
fn test_render_grain_type_block() {
let t = parse_template(concat!(
"{{#each grains}}",
"{{#grain_type \"fact\"}}FACT: {{subject}}{{/grain_type}}",
"{{#grain_type \"tool\"}}TOOL: {{tool_name}}{{/grain_type}}",
"\n{{/each}}"
))
.unwrap();
let grains = vec![
make_fact("john", "mg:likes", "coffee", 0.94),
make_tool("search", "weather", "sunny", false),
];
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: grains.len(),
user_vars: HashMap::new(),
};
let result = render(&t, &grains, &ctx).unwrap();
assert_eq!(result, "FACT: john\nTOOL: search\n");
}
#[test]
fn test_render_metadata_variables() {
let t = parse_template(
"Count: {{_count}}, {{#each grains}}[{{_index}}]{{subject}}{{#if _last}} END{{/if}} {{/each}}"
).unwrap();
let grains = vec![
make_fact("john", "mg:likes", "coffee", 0.94),
make_fact("bob", "mg:knows", "john", 0.87),
];
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: grains.len(),
user_vars: HashMap::new(),
};
let result = render(&t, &grains, &ctx).unwrap();
assert_eq!(result, "Count: 2, [0]john [1]bob END ");
}
#[test]
fn test_render_output_size_limit() {
let t = parse_template("{{#each grains}}{{subject}}{{/each}}").unwrap();
let grains: Vec<CalGrainResult> = (0..1000)
.map(|i| make_fact(&format!("subject_{}", i), "mg:knows", "object", 0.5))
.collect();
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: grains.len(),
user_vars: HashMap::new(),
};
let result = render_with_limit(&t, &grains, &ctx, 100);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E050");
}
#[test]
fn test_default_grain_render_fact() {
let grain = make_fact("john", "mg:likes", "coffee", 0.94);
let ctx = test_ctx();
let result = default_grain_render(&grain, &ctx);
assert_eq!(result, "john likes coffee (94%)");
}
#[test]
fn test_default_grain_render_tool() {
let grain = make_tool("search", "weather", "sunny", false);
let ctx = test_ctx();
let result = default_grain_render(&grain, &ctx);
assert_eq!(result, "search(weather) -> sunny");
}
#[test]
fn test_default_grain_render_tool_error() {
let grain = make_tool("search", "weather", "timeout", true);
let ctx = test_ctx();
let result = default_grain_render(&grain, &ctx);
assert_eq!(result, "search(weather) -> timeout [ERROR]");
}
#[test]
fn test_default_grain_render_event() {
let grain = make_event("john", "logged in");
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: HashMap::new(),
};
let result = default_grain_render(&grain, &ctx);
assert_eq!(result, "john logged in [1m ago]");
}
#[test]
fn test_default_grain_render_goal() {
let grain = make_goal("Complete onboarding", "active");
let ctx = test_ctx();
let result = default_grain_render(&grain, &ctx);
assert_eq!(result, "active - john (high)");
}
#[test]
fn test_select_tier() {
assert_eq!(select_tier(10000, 10), DisclosureTier::Full); assert_eq!(select_tier(1000, 10), DisclosureTier::Headlines); assert_eq!(select_tier(100, 10), DisclosureTier::Summary); assert_eq!(select_tier(1000, 0), DisclosureTier::Full); }
#[test]
fn test_effective_truncate() {
assert_eq!(effective_truncate(Some(100), DisclosureTier::Summary), 40);
assert_eq!(effective_truncate(Some(100), DisclosureTier::Headlines), 80);
assert_eq!(effective_truncate(Some(100), DisclosureTier::Full), 100);
assert_eq!(effective_truncate(None, DisclosureTier::Summary), 40);
assert_eq!(effective_truncate(None, DisclosureTier::Full), usize::MAX);
}
#[test]
fn test_tier_affects_truncation() {
let t = parse_template("{{#each grains}}{{content | truncate 100}}{{/each}}").unwrap();
let grains = vec![{
let mut g = make_fact("john", "mg:likes", "coffee", 0.94);
g.fields
.as_object_mut()
.unwrap()
.insert("content".into(), json!("x".repeat(200)));
g
}];
let ctx_full = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: HashMap::new(),
};
let result_full = render(&t, &grains, &ctx_full).unwrap();
assert!(result_full.len() <= 103 + 3);
let ctx_summary = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Summary,
total_count: 1,
user_vars: HashMap::new(),
};
let result_summary = render(&t, &grains, &ctx_summary).unwrap();
assert!(result_summary.len() <= 43 + 3); assert!(result_summary.len() < result_full.len());
}
#[test]
fn test_registry_has_builtins() {
let reg = TemplateRegistry::new();
assert!(reg.get("structured").is_some());
assert!(reg.get("readable").is_some());
assert!(reg.get("compact").is_some());
assert!(reg.get("readable").unwrap().builtin);
assert_eq!(reg.list().len(), 3);
for name in super::FORMAT_ARM_NAMES {
assert!(reg.get(name).is_none(), "builtin shadows FORMAT {name}");
}
}
#[test]
fn test_limit_template_body_size() {
assert_eq!(MAX_TEMPLATE_SIZE, 4_096, "§10.8 max template body size");
let ok = "x".repeat(MAX_TEMPLATE_SIZE);
assert!(parse_template(&ok).is_ok());
let too_big = "x".repeat(MAX_TEMPLATE_SIZE + 1);
assert!(matches!(
parse_template(&too_big),
Err(CalError::TemplateTooLarge { .. })
));
}
#[test]
fn test_limit_conditional_nesting_depth() {
let nest = |n: usize| {
let mut t = String::new();
for _ in 0..n {
t.push_str("{{#grain.confidence}}");
}
t.push('x');
for _ in 0..n {
t.push_str("{{/grain.confidence}}");
}
t
};
assert!(parse_template(&nest(MAX_TEMPLATE_NESTING)).is_ok());
assert!(matches!(
parse_template(&nest(MAX_TEMPLATE_NESTING + 2)),
Err(CalError::TemplateNestingTooDeep { .. })
));
}
#[test]
fn test_limit_each_iterations() {
let grains: Vec<CalGrainResult> = (0..MAX_EACH_ITERATIONS + 50)
.map(|i| CalGrainResult {
hash: format!("{i:064}"),
grain_type: "fact".into(),
score: 1.0,
fields: serde_json::json!({ "subject": "s" }),
is_deterministic: true,
contested_by: None,
explanation: None,
score_breakdown: None,
relative_time: None,
})
.collect();
let t = parse_template("{{#each grains}}x{{/each}}").unwrap();
let ctx = RenderContext {
now_secs: 0,
tier: DisclosureTier::Full,
total_count: grains.len(),
user_vars: HashMap::new(),
};
assert_eq!(render(&t, &grains, &ctx).unwrap().len(), MAX_EACH_ITERATIONS);
}
#[test]
fn test_limit_templates_per_namespace() {
let mut reg = TemplateRegistry::new();
for i in 0..MAX_TEMPLATES {
reg.register(&format!("t{i}"), "{{grain.content}}", "", None)
.unwrap_or_else(|e| panic!("template {i} should register: {e}"));
}
assert!(matches!(
reg.register("one_too_many", "{{grain.content}}", "", None),
Err(CalError::TooManyTemplates { .. })
));
assert!(reg.register("t0", "{{grain.subject}}", "", None).is_ok());
}
#[test]
fn test_data_preset_cannot_be_extended() {
let mut reg = TemplateRegistry::new();
assert!(matches!(
reg.register("x", "{{grain.content}}", "", Some("data")),
Err(CalError::CannotExtendData { .. })
));
}
#[test]
fn test_registry_builtin_count() {
let reg = TemplateRegistry::new();
let list = reg.list();
assert_eq!(list.len(), 3);
assert!(list.iter().all(|e| e.builtin));
for preset in ["structured", "readable", "compact"] {
assert!(
reg.get(preset).is_some_and(|e| e.template.is_sectioned()),
"preset {preset} must exist and be sectioned"
);
}
assert!(reg.get("data").is_none(), "`data` is not an extendable template");
}
#[test]
fn test_registry_register_custom() {
let mut reg = TemplateRegistry::new();
reg.register(
"my_template",
"{{#each grains}}{{subject}}{{/each}}",
"A custom template",
None,
)
.unwrap();
assert!(reg.get("my_template").is_some());
assert!(!reg.get("my_template").unwrap().builtin);
assert_eq!(reg.list().len(), 4);
}
#[test]
fn test_registry_cannot_overwrite_builtin() {
let mut reg = TemplateRegistry::new();
let result = reg.register("readable", "{{subject}}", "override", None);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E046");
}
#[test]
fn test_registry_invalid_name_rejected() {
let mut reg = TemplateRegistry::new();
let result = reg.register("123-starts-with-digit", "{{subject}}", "bad name", None);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E115");
}
#[test]
fn test_registry_delete_custom() {
let mut reg = TemplateRegistry::new();
reg.register(
"my_template",
"{{#each grains}}{{subject}}{{/each}}",
"test",
None,
)
.unwrap();
assert!(reg.get("my_template").is_some());
reg.delete("my_template").unwrap();
assert!(reg.get("my_template").is_none());
}
#[test]
fn test_registry_cannot_delete_builtin() {
let mut reg = TemplateRegistry::new();
let result = reg.delete("readable");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E046");
}
#[test]
fn test_registry_delete_nonexistent() {
let mut reg = TemplateRegistry::new();
let result = reg.delete("nonexistent");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E045");
}
#[test]
fn test_registry_inheritance() {
let mut reg = TemplateRegistry::new();
reg.register(
"parent_tpl",
concat!(
"{{#each grains}}",
"{{#grain_type \"fact\"}}B: {{subject}}{{/grain_type}}",
"{{#grain_type \"tool\"}}A: {{tool_name}}{{/grain_type}}",
"{{/each}}"
),
"Parent",
None,
)
.unwrap();
reg.register(
"child_tpl",
"{{#grain_type \"fact\"}}CUSTOM: {{subject}} -> {{object}}{{/grain_type}}",
"Child",
Some("parent_tpl"),
)
.unwrap();
let child = reg.get("child_tpl").unwrap();
let resolved = resolve_inheritance(child, ®);
let grains = vec![
make_fact("john", "mg:likes", "coffee", 0.94),
make_tool("search", "weather", "sunny", false),
];
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: grains.len(),
user_vars: HashMap::new(),
};
let result = render(&resolved, &grains, &ctx).unwrap();
assert!(result.contains("CUSTOM: john -> coffee"));
assert!(result.contains("A: search"));
}
#[test]
fn test_registry_2_level_inheritance_rejected() {
let mut reg = TemplateRegistry::new();
reg.register(
"grandparent",
"{{#each grains}}{{subject}}{{/each}}",
"GP",
None,
)
.unwrap();
reg.register(
"parent_tpl",
"{{#each grains}}{{subject}}{{/each}}",
"P",
Some("grandparent"),
)
.unwrap();
let result = reg.register(
"child_tpl",
"{{#each grains}}{{subject}}{{/each}}",
"C",
Some("parent_tpl"),
);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E048");
}
#[test]
fn test_registry_parent_not_found() {
let mut reg = TemplateRegistry::new();
let result = reg.register(
"child_tpl",
"{{#each grains}}{{subject}}{{/each}}",
"C",
Some("nonexistent"),
);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E047");
}
#[test]
fn test_apply_format() {
let mut reg = TemplateRegistry::new();
reg.register(
"one_line_triples",
"{{#each grains}}{{subject}} {{relation | humanize}} {{object}}\n{{/each}}",
"test",
None,
)
.unwrap();
let entry = reg.get("one_line_triples").unwrap();
let grains = vec![make_fact("john", "mg:likes", "coffee", 0.94)];
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: grains.len(),
user_vars: HashMap::new(),
};
let result = apply_format(&grains, &entry.template, &ctx).unwrap();
assert!(result.contains("john likes coffee"));
}
#[test]
fn test_empty_template() {
let t = parse_template("").unwrap();
let ctx = test_ctx();
let result = render(&t, &[], &ctx).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_empty_grains() {
let t = parse_template("Before{{#each grains}}{{subject}}{{/each}}After").unwrap();
let ctx = test_ctx();
let result = render(&t, &[], &ctx).unwrap();
assert_eq!(result, "BeforeAfter");
}
#[test]
fn test_unicode_truncation() {
let ctx = test_ctx();
let val =
ResolvedValue::Str("\u{3053}\u{3093}\u{306b}\u{3061}\u{306f}\u{4e16}\u{754c}".into()); let filter = Filter {
name: "truncate".into(),
arg: Some("3".into()),
};
let result = apply_filter(&val, &filter, &ctx).unwrap();
let display = result.to_display();
assert!(display.ends_with("..."));
assert_eq!(display, "\u{3053}\u{3093}\u{306b}...");
}
#[test]
fn test_render_with_null_fields() {
let grain = CalGrainResult {
hash: "abc123".to_string(),
grain_type: "fact".to_string(),
score: 0.5,
fields: json!({}),
score_breakdown: None,
explanation: None,
relative_time: None,
is_deterministic: false,
contested_by: None,
};
let t =
parse_template("{{#each grains}}{{subject}} {{relation}} {{object}}{{/each}}").unwrap();
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: HashMap::new(),
};
let result = render(&t, &[grain], &ctx).unwrap();
assert_eq!(result, " ");
}
#[test]
fn test_parse_date_filter_with_quoted_arg() {
let t = parse_template("{{created_at | date \"%Y-%m-%d %H:%M\"}}").unwrap();
match &t.nodes()[0] {
TemplateNode::Variable { filters, .. } => {
assert_eq!(filters[0].name, "date");
assert_eq!(filters[0].arg, Some("%Y-%m-%d %H:%M".to_string()));
}
_ => panic!("expected Variable"),
}
}
#[test]
fn test_parse_default_filter_with_quoted_arg() {
let t = parse_template("{{summary | default \"No summary\"}}").unwrap();
match &t.nodes()[0] {
TemplateNode::Variable { filters, .. } => {
assert_eq!(filters[0].name, "default");
assert_eq!(filters[0].arg, Some("No summary".to_string()));
}
_ => panic!("expected Variable"),
}
}
#[test]
fn test_template_source_preserved() {
let source = "{{#each grains}}{{subject}}{{/each}}";
let t = parse_template(source).unwrap();
assert_eq!(t.source(), source);
}
#[test]
fn test_registry_list_sorted() {
let reg = TemplateRegistry::new();
let list = reg.list();
let ts: Vec<u64> = list.iter().map(|e| e.updated_at.unwrap_or(0)).collect();
assert!(
ts.windows(2).all(|w| w[0] >= w[1]),
"list is not sorted by updated_at DESC: {:?}",
ts
);
}
#[test]
fn test_select_tier_boundary_values() {
assert_eq!(select_tier(2000, 10), DisclosureTier::Full);
assert_eq!(select_tier(1990, 10), DisclosureTier::Headlines);
assert_eq!(select_tier(800, 10), DisclosureTier::Headlines);
assert_eq!(select_tier(790, 10), DisclosureTier::Summary);
}
#[test]
fn test_suggestion_for_unknown_variable() {
let result = parse_template("{{subjet}}"); assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code(), "CAL-E042");
let suggestion = err.suggestion();
assert!(suggestion.is_some());
assert!(suggestion.unwrap().contains("subject"));
}
#[test]
fn test_user_var_parse_valid() {
let t = parse_template("{{$user_query}}");
assert!(t.is_ok(), "should accept $-prefixed variable");
}
#[test]
fn test_user_var_parse_with_filter() {
let t = parse_template("{{$name | uppercase}}");
assert!(t.is_ok(), "should accept $-prefixed variable with filter");
}
#[test]
fn test_user_var_parse_in_condition() {
let t = parse_template("{{#if $show_header}}Header{{/if}}");
assert!(t.is_ok(), "should accept $-prefixed variable in condition");
}
#[test]
fn test_user_var_parse_invalid_name() {
let result = parse_template("{{$123bad}}");
assert!(
result.is_err(),
"should reject $-prefixed var starting with digit"
);
}
#[test]
fn test_user_var_resolve_found() {
let t = parse_template("Query: {{$user_query}}").unwrap();
let mut vars = HashMap::new();
vars.insert("user_query".into(), "what does john like?".into());
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 0,
user_vars: vars,
};
let result = render(&t, &[], &ctx).unwrap();
assert_eq!(result, "Query: what does john like?");
}
#[test]
fn test_user_var_resolve_missing() {
let t = parse_template("{{$missing_var}}").unwrap();
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 0,
user_vars: HashMap::new(),
};
let result = render(&t, &[], &ctx).unwrap();
assert_eq!(
result, "",
"missing user var should resolve to empty string"
);
}
#[test]
fn test_user_var_with_filter() {
let t = parse_template("{{$name | uppercase}}").unwrap();
let mut vars = HashMap::new();
vars.insert("name".into(), "john".into());
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 0,
user_vars: vars,
};
let result = render(&t, &[], &ctx).unwrap();
assert_eq!(result, "JOHN");
}
#[test]
fn test_user_var_with_grain_fields() {
let t = parse_template("{{$label}}: {{#each}}{{subject}} {{relation}} {{object}}{{/each}}")
.unwrap();
let grain = make_fact("john", "likes", "coffee", 0.9);
let mut vars = HashMap::new();
vars.insert("label".into(), "Results".into());
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: vars,
};
let result = render(&t, &[grain], &ctx).unwrap();
assert_eq!(result, "Results: john likes coffee");
}
#[test]
fn test_user_var_condition_truthy() {
let t = parse_template("{{#if $show}}visible{{/if}}").unwrap();
let mut vars = HashMap::new();
vars.insert("show".into(), "yes".into());
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 0,
user_vars: vars,
};
let result = render(&t, &[], &ctx).unwrap();
assert_eq!(result, "visible");
}
#[test]
fn test_user_var_condition_falsy() {
let t = parse_template("{{#if $show}}visible{{/if}}").unwrap();
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 0,
user_vars: HashMap::new(),
};
let result = render(&t, &[], &ctx).unwrap();
assert_eq!(result, "");
}
#[test]
fn test_user_var_condition_empty_string_is_falsy() {
let t = parse_template("{{#if $show}}visible{{/if}}").unwrap();
let mut vars = HashMap::new();
vars.insert("show".into(), "".into());
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 0,
user_vars: vars,
};
let result = render(&t, &[], &ctx).unwrap();
assert_eq!(result, "", "empty-string user var should be falsy");
}
#[test]
fn test_user_var_no_collision_with_grain_fields() {
let t = parse_template("{{#each}}grain:{{subject}} user:{{$subject}}{{/each}}").unwrap();
let grain = make_fact("john", "likes", "coffee", 0.9);
let mut vars = HashMap::new();
vars.insert("subject".into(), "override_attempt".into());
let ctx = RenderContext {
now_secs: 1700000100,
tier: DisclosureTier::Full,
total_count: 1,
user_vars: vars,
};
let result = render(&t, &[grain], &ctx).unwrap();
assert_eq!(result, "grain:john user:override_attempt");
}
}