use std::{
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
sync::Arc,
};
use inflector::Inflector;
use schemars::JsonSchema;
use serde::Deserialize;
use tera::{Tera, Value};
use crate::{
error::{AppError, Result, TemplateFailureCause},
graph::{EngineQueryResults, EngineRow, EngineValue, GraphEngine},
};
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Frontmatter {
pub to: String,
#[serde(default, deserialize_with = "sparql_map")]
pub sparql: BTreeMap<String, String>,
#[serde(default)]
pub for_each: Option<String>,
#[serde(default)]
pub construct: Option<String>,
#[serde(default)]
pub inject: bool,
#[serde(default)]
pub before: Option<MatchSpec>,
#[serde(default)]
pub after: Option<MatchSpec>,
#[serde(default)]
pub at_line: Option<usize>,
#[serde(default)]
pub skip_if: Option<MatchSpec>,
#[serde(default)]
pub unless_exists: bool,
#[serde(default)]
pub unattended_write_eligible: bool,
#[serde(default)]
pub force: bool,
#[serde(default)]
pub when: Option<String>,
#[serde(default)]
pub skip_empty: bool,
#[serde(default)]
pub from: Option<String>,
#[serde(default, alias = "sh")]
pub sh_before: Option<String>,
#[serde(default)]
pub sh_after: Option<String>,
#[serde(default)]
pub backup: bool,
#[serde(default)]
pub shape: Vec<String>,
#[serde(default)]
pub determinism: Option<bool>,
#[serde(default)]
pub freeze_policy: Option<FreezePolicy>,
#[serde(default)]
pub freeze_slots_dir: Option<String>,
#[serde(default, deserialize_with = "string_or_seq")]
pub rdf: Vec<String>,
#[serde(default, deserialize_with = "string_or_seq")]
pub rdf_inline: Vec<String>,
#[serde(default)]
pub prefixes: BTreeMap<String, String>,
#[serde(default)]
pub base: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum MatchSpec {
Literal(String),
Structured(MatchRule),
}
impl MatchSpec {
#[must_use]
pub fn pattern(&self) -> &str {
match self {
Self::Literal(pattern) => pattern,
Self::Structured(rule) => &rule.pattern,
}
}
#[must_use]
pub fn with_pattern(&self, pattern: String) -> Self {
match self {
Self::Literal(_) => Self::Literal(pattern),
Self::Structured(rule) => {
let mut rendered = rule.clone();
rendered.pattern = pattern;
Self::Structured(rendered)
}
}
}
#[must_use]
pub fn is_structured(&self) -> bool {
matches!(self, Self::Structured(_))
}
}
impl From<String> for MatchSpec {
fn from(value: String) -> Self {
Self::Literal(value)
}
}
impl From<&str> for MatchSpec {
fn from(value: &str) -> Self {
Self::Literal(value.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct MatchRule {
pub pattern: String,
#[serde(default)]
pub matcher: MatchKind,
#[serde(default)]
pub scope: MatchScope,
#[serde(default)]
pub occurrence: MatchOccurrence,
#[serde(default = "default_match_index")]
pub index: usize,
#[serde(default = "default_true")]
pub case_sensitive: bool,
#[serde(default)]
pub trim: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MatchKind {
#[default]
Contains,
Exact,
Regex,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MatchScope {
#[default]
Auto,
Line,
File,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MatchOccurrence {
#[default]
First,
Last,
Unique,
Nth,
}
const fn default_match_index() -> usize {
1
}
const fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FreezePolicy {
Never,
Always,
Checksum,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Template {
pub frontmatter: Frontmatter,
pub body: String,
}
impl Template {
pub fn parse(content: &str) -> Result<Self> {
let rest = content.strip_prefix("---").ok_or_else(|| {
AppError::fm_tpl(
1,
"template must start with a `---` frontmatter block. \
Remediation: begin the file with `---`, YAML keys, `---`.",
)
})?;
let rest = rest.strip_prefix('\n').ok_or_else(|| {
AppError::fm_tpl(12, "`---` frontmatter delimiter must be on its own line")
})?;
let (yaml, body) = split_closing_delimiter(rest).ok_or_else(|| {
AppError::fm_tpl(
13,
"unterminated frontmatter: no closing `---` line found. \
Remediation: close the YAML block with a `---` line.",
)
})?;
let frontmatter: Frontmatter = serde_yaml::from_str(yaml).map_err(|e| {
AppError::fm_tpl(
2,
format!(
"frontmatter rejected: {e}. \
Remediation: use only the closed key set (to, sparql, for_each, construct, \
inject, before, after, at_line, skip_if, unless_exists, \
unattended_write_eligible, force, \
when, skip_empty, from, sh_before, sh_after, backup, shape, \
determinism, freeze_policy, freeze_slots_dir, rdf, rdf_inline, \
prefixes, base)."
),
)
})?;
if frontmatter.unattended_write_eligible && !frontmatter.unless_exists {
return Err(AppError::fm_tpl(
27,
"unattended_write_eligible: true requires unless_exists: true in the same \
frontmatter block -- eligibility for ggen-mcp's bounded unattended-write \
dispatcher (Gall CP31-CP33) depends on the create-only guarantee \
unless_exists provides; a rule that could overwrite existing content is \
never eligible for a zero-decision-step write. \
Remediation: add `unless_exists: true`, or remove \
`unattended_write_eligible` if this rule may overwrite existing content.",
));
}
Ok(Self {
frontmatter,
body: body.to_string(),
})
}
}
fn split_closing_delimiter(rest: &str) -> Option<(&str, &str)> {
let mut offset = 0usize;
for line in rest.split_inclusive('\n') {
if line.trim_end_matches(['\r', '\n']) == "---" {
let yaml = &rest[..offset];
let body = &rest[offset + line.len()..];
return Some((yaml, body));
}
offset += line.len();
}
None
}
fn string_or_seq<'de, D>(de: D) -> std::result::Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{Error as DeError, SeqAccess, Visitor};
use std::fmt;
struct StrOrSeq;
impl<'de> Visitor<'de> for StrOrSeq {
type Value = Vec<String>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a string or a sequence of strings")
}
fn visit_str<E>(self, v: &str) -> std::result::Result<Self::Value, E>
where
E: DeError,
{
Ok(vec![v.to_string()])
}
fn visit_string<E>(self, v: String) -> std::result::Result<Self::Value, E>
where
E: DeError,
{
Ok(vec![v])
}
fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut out = Vec::new();
while let Some(s) = seq.next_element::<String>()? {
out.push(s);
}
Ok(out)
}
}
de.deserialize_any(StrOrSeq)
}
fn sparql_map<'de, D>(de: D) -> std::result::Result<BTreeMap<String, String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMapOrSeq {
One(String),
Map(BTreeMap<String, String>),
Seq(Vec<String>),
}
match OneOrMapOrSeq::deserialize(de)? {
OneOrMapOrSeq::One(q) => {
let mut m = BTreeMap::new();
m.insert("default".to_string(), q);
Ok(m)
}
OneOrMapOrSeq::Map(m) => Ok(m),
OneOrMapOrSeq::Seq(queries) => {
let mut m = BTreeMap::new();
for (i, query) in queries.into_iter().enumerate() {
m.insert(format!("query_{i}"), query);
}
Ok(m)
}
}
}
pub fn build_tera(graph: Arc<dyn GraphEngine>) -> Result<Tera> {
build_tera_with_packs(graph, &[])
}
pub fn build_tera_with_packs(
graph: Arc<dyn GraphEngine>, packs: &[crate::pack::Pack],
) -> Result<Tera> {
let mut tera = load_templates_glob_lenient(Path::new("templates"))?;
attach_pack_templates(&mut tera, packs)?;
tera.register_function("sparql", move |args: &HashMap<String, Value>| {
let query = args
.get("query")
.and_then(Value::as_str)
.ok_or_else(|| tera::Error::msg("sparql() requires a string `query` argument"))?;
sparql_to_value(graph.as_ref(), query).map_err(|e| tera::Error::msg(e.to_string()))
});
tera.register_function("local", local_fn);
tera.register_function("sparql_first", sparql_first_fn);
tera.register_function("sparql_values", sparql_values_fn);
tera.register_function("sparql_empty", sparql_empty_fn);
tera.register_function("sparql_count", sparql_count_fn);
tera.register_filter("snake_case", snake_case_filter);
tera.register_filter("snake", snake_case_filter);
tera.register_filter("pascal_case", pascal_case_filter);
tera.register_filter("pascal", pascal_case_filter);
tera.register_filter("camel_case", camel_case_filter);
tera.register_filter("camel", camel_case_filter);
tera.register_filter("kebab_case", kebab_case_filter);
tera.register_filter("kebab", kebab_case_filter);
tera.register_filter("shouty_snake_case", shouty_snake_case_filter);
tera.register_filter("shouty", shouty_snake_case_filter);
tera.register_filter("title_case", title_case_filter);
tera.register_filter("title", title_case_filter);
tera.register_filter("pluralize", pluralize_filter);
tera.register_filter("singularize", singularize_filter);
tera.register_filter("ordinalize", ordinalize_filter);
tera.register_filter("demodulize", demodulize_filter);
tera.register_filter("foreign_key", foreign_key_filter);
tera.register_filter("tableize", tableize_filter);
tera.register_filter("classify", classify_filter);
tera.register_filter("hex_to_u64", hex_to_u64_filter);
Ok(tera)
}
pub(crate) fn load_templates_glob_lenient(templates_dir: &Path) -> Result<Tera> {
if !templates_dir.is_dir() {
return Ok(Tera::default());
}
let canonical_root = templates_dir.canonicalize().map_err(|e| {
AppError::fm_tpl(
15,
format!(
"`{}` directory unreadable: {e}. Remediation: check its permissions.",
templates_dir.display()
),
)
})?;
let mut files: Vec<PathBuf> = Vec::new();
collect_files_recursive(&canonical_root, &mut files).map_err(|e| {
AppError::fm_tpl(
15,
format!(
"walking `{}` failed: {e}. Remediation: check its permissions.",
templates_dir.display()
),
)
})?;
files.sort();
let mut tera = Tera::default();
for path in files {
let rel_name = path
.strip_prefix(&canonical_root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
template.path = %rel_name,
template.error = %e,
"templates/**/* glob: skipping unreadable template file \
(it will still fail loudly if a rule or include actually uses it)",
);
continue;
}
}; match tera::Template::new(
&rel_name,
Some(path.to_string_lossy().to_string()),
&content,
) {
Ok(tpl) => {
tera.templates.insert(rel_name, tpl);
}
Err(e) => {
tracing::warn!(
template.path = %rel_name,
template.error = %e,
"templates/**/* glob: skipping unparseable template file \
(it will still fail loudly if a rule or include actually uses it)",
);
}
}
}
tera.build_inheritance_chains().map_err(|e| {
AppError::fm_tpl(
15,
format!(
"Tera inheritance chain build failed across `{}`: {e}. \
Remediation: fix the reported `extends`/parent relationship.",
templates_dir.display()
),
)
})?;
tera.check_macro_files().map_err(|e| {
AppError::fm_tpl(
15,
format!(
"Tera macro import check failed across `{}`: {e}. \
Remediation: fix the reported macro import path.",
templates_dir.display()
),
)
})?;
Ok(tera)
}
fn collect_files_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_files_recursive(&path, out)?;
} else if path.is_file() {
out.push(path);
}
}
Ok(())
}
fn attach_pack_templates(tera: &mut Tera, packs: &[crate::pack::Pack]) -> Result<()> {
if packs.is_empty() {
return Ok(());
}
for pack in packs {
let templates_dir = pack.root.join("templates");
if !templates_dir.is_dir() {
continue;
}
let canonical_root = templates_dir.canonicalize().map_err(|e| {
AppError::fm_tpl(
15,
format!(
"pack `{}` templates directory `{}` unreadable: {e}",
pack.name,
templates_dir.display()
),
)
})?;
let mut files: Vec<PathBuf> = Vec::new();
collect_files_recursive(&canonical_root, &mut files).map_err(|e| {
AppError::fm_tpl(
15,
format!(
"walking pack `{}` templates `{}` failed: {e}",
pack.name,
templates_dir.display()
),
)
})?;
files.sort();
for path in files {
let rel = path
.strip_prefix(&canonical_root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let registered_name = format!("{}://{}", pack.name, rel);
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
template.name = %registered_name,
template.error = %e,
"pack templates: skipping unreadable pack template file",
);
continue;
}
};
match tera::Template::new(
®istered_name,
Some(path.to_string_lossy().to_string()),
&content,
) {
Ok(tpl) => {
tera.templates.insert(registered_name, tpl);
}
Err(e) => {
tracing::warn!(
template.name = %registered_name,
template.error = %e,
"pack templates: skipping unparseable pack template file \
(it will still fail loudly if a rule or include actually uses it)",
);
}
}
}
}
tera.build_inheritance_chains().map_err(|e| {
AppError::fm_tpl(
15,
format!("Tera inheritance chain build failed across project+pack templates: {e}"),
)
})?;
tera.check_macro_files().map_err(|e| {
AppError::fm_tpl(
15,
format!("Tera macro import check failed across project+pack templates: {e}"),
)
})?;
Ok(())
}
pub fn resolve_pack_template(uri: &str, packs: &[crate::pack::Pack]) -> Result<PathBuf> {
let Some((pack_name, subpath)) = uri.split_once("://") else {
return Err(AppError::fm_tpl(
28,
format!("`{uri}` is not a `<pack-name>://<subpath>` template URI"),
));
};
let Some(pack) = packs.iter().find(|p| p.name == pack_name) else {
let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
return Err(AppError::fm_tpl(
28,
format!(
"template URI `{uri}` names unknown pack `{pack_name}`; resolved packs: [{}]",
names.join(", ")
),
));
};
if subpath.split('/').any(|seg| seg == "..") {
return Err(AppError::fm_tpl(
28,
format!("template URI `{uri}` contains a `..` segment; traversal refused"),
));
}
let searched = pack.root.join("templates").join(subpath.replace('\\', "/"));
if !searched.is_file() {
return Err(AppError::fm_tpl(
28,
format!(
"template URI `{uri}` does not exist: searched `{}`",
searched.display()
),
));
}
Ok(searched)
}
pub(crate) fn tera_error_full_chain(err: &tera::Error) -> String {
use std::error::Error as StdError;
let mut parts = vec![err.to_string()];
let mut cause = StdError::source(err);
while let Some(e) = cause {
parts.push(e.to_string());
cause = e.source();
}
parts.join(": ")
}
pub(crate) fn tera_error_location(err: &tera::Error) -> Option<String> {
tera_error_full_chain(err).lines().find_map(|line| {
let trimmed = line.trim_start();
let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
let rest = trimmed[digits.len()..].trim_start();
rest.starts_with('|').then(|| format!("line {digits}"))
})
}
pub(crate) fn classify_tera_render_error(
err: &tera::Error, own_template_name: &str,
) -> TemplateFailureCause {
use std::error::Error as StdError;
let mut current = err;
loop {
match ¤t.kind {
tera::ErrorKind::Msg(_) => {
match StdError::source(current).and_then(|s| s.downcast_ref::<tera::Error>()) {
Some(inner) => {
current = inner;
}
None => return classify_msg(¤t.to_string()),
}
}
other => return classify_kind(other, own_template_name),
}
}
}
fn classify_kind(kind: &tera::ErrorKind, own_template_name: &str) -> TemplateFailureCause {
match kind {
tera::ErrorKind::TemplateNotFound(name) => {
if name == own_template_name {
TemplateFailureCause::TemplateNotFound
} else {
TemplateFailureCause::TemplateIncludeNotFound
}
}
tera::ErrorKind::MissingParent { .. } | tera::ErrorKind::CircularExtend { .. } => {
TemplateFailureCause::TemplateIncludeNotFound
}
tera::ErrorKind::FilterNotFound(_)
| tera::ErrorKind::FunctionNotFound(_)
| tera::ErrorKind::TestNotFound(_) => TemplateFailureCause::TemplateFilterUnknown,
tera::ErrorKind::CallFunction(_)
| tera::ErrorKind::CallFilter(_)
| tera::ErrorKind::CallTest(_)
| tera::ErrorKind::Json(_) => TemplateFailureCause::TemplateContextInvalid,
tera::ErrorKind::Io(_) | tera::ErrorKind::Utf8Conversion { .. } => {
TemplateFailureCause::TemplateRenderInternal
}
tera::ErrorKind::InvalidMacroDefinition(_) => TemplateFailureCause::TemplateParseFailed,
tera::ErrorKind::Msg(msg) => classify_msg(msg),
_ => TemplateFailureCause::TemplateRenderInternal,
}
}
fn classify_msg(msg: &str) -> TemplateFailureCause {
if msg.contains("not found in context") {
TemplateFailureCause::TemplateVariableMissing
} else if msg.contains("Failed to parse") || msg.contains("expected") {
TemplateFailureCause::TemplateParseFailed
} else if msg.contains("is not a number") || msg.contains("can not be evaluated") {
TemplateFailureCause::TemplateContextInvalid
} else {
TemplateFailureCause::TemplateRenderInternal
}
}
pub(crate) fn sparql_to_value(graph: &dyn GraphEngine, query: &str) -> Result<Value> {
match graph.query(query)? {
EngineQueryResults::Boolean(b) => Ok(Value::Bool(b)),
EngineQueryResults::Solutions(solutions) => {
Ok(Value::Array(solutions_to_values(solutions)))
}
EngineQueryResults::Graph(triples) => {
let rows = triples
.into_iter()
.map(|triple| {
let mut row = tera::Map::new();
row.insert("subject".to_string(), Value::String(triple.subject));
row.insert("predicate".to_string(), Value::String(triple.predicate));
row.insert("object".to_string(), Value::String(triple.object_value));
Value::Object(row)
})
.collect();
Ok(Value::Array(rows))
}
}
}
pub(crate) fn solutions_to_values(rows: Vec<EngineRow>) -> Vec<Value> {
rows.into_iter()
.map(|solution| {
let mut row = tera::Map::new();
for (var, value) in solution {
row.insert(var, engine_value_to_tera(value));
}
Value::Object(row)
})
.collect()
}
fn engine_value_to_tera(value: EngineValue) -> Value {
match value {
EngineValue::Bool(b) => Value::Bool(b),
EngineValue::Int(n) => Value::Number(n.into()),
EngineValue::Float(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
EngineValue::String(s) => Value::String(s),
}
}
fn local_fn(args: &HashMap<String, Value>) -> tera::Result<Value> {
let iri = args
.get("iri")
.and_then(Value::as_str)
.ok_or_else(|| tera::Error::msg("local() requires a string `iri` argument"))?;
let local = iri
.rsplit_once('#')
.map(|(_, frag)| frag)
.or_else(|| iri.rsplit_once('/').map(|(_, seg)| seg))
.unwrap_or(iri);
Ok(Value::String(local.to_string()))
}
fn rows_or_results_arg<'a>(
args: &'a HashMap<String, Value>, fn_name: &str,
) -> tera::Result<&'a Vec<Value>> {
match (args.get("rows"), args.get("results")) {
(Some(_), Some(_)) => Err(tera::Error::msg(format!(
"{fn_name}() accepts either `rows` or `results`, not both — pick one convention"
))),
(Some(v), None) => v.as_array().ok_or_else(|| {
tera::Error::msg(format!("{fn_name}() requires an array `rows` argument"))
}),
(None, Some(v)) => v.as_array().ok_or_else(|| {
tera::Error::msg(format!("{fn_name}() requires an array `results` argument"))
}),
(None, None) => Err(tera::Error::msg(format!(
"{fn_name}() requires an array `rows` or `results` argument"
))),
}
}
fn sparql_first_fn(args: &HashMap<String, Value>) -> tera::Result<Value> {
let rows = rows_or_results_arg(args, "sparql_first")?;
let Some(first) = rows.first() else {
return Ok(Value::Null);
};
match args.get("column").and_then(Value::as_str) {
Some(column) => Ok(first
.as_object()
.and_then(|o| o.get(column))
.cloned()
.unwrap_or(Value::Null)),
None => Ok(first.clone()),
}
}
fn sparql_values_fn(args: &HashMap<String, Value>) -> tera::Result<Value> {
let rows = rows_or_results_arg(args, "sparql_values")?;
let column = args
.get("column")
.and_then(Value::as_str)
.ok_or_else(|| tera::Error::msg("sparql_values() requires a string `column` argument"))?;
let values: Vec<Value> = rows
.iter()
.map(|row| {
row.as_object()
.and_then(|o| o.get(column))
.cloned()
.unwrap_or(Value::Null)
})
.collect();
Ok(Value::Array(values))
}
fn sparql_empty_fn(args: &HashMap<String, Value>) -> tera::Result<Value> {
let rows = rows_or_results_arg(args, "sparql_empty")?;
Ok(Value::Bool(rows.is_empty()))
}
fn sparql_count_fn(args: &HashMap<String, Value>) -> tera::Result<Value> {
let rows = rows_or_results_arg(args, "sparql_count")?;
Ok(Value::Number(rows.len().into()))
}
fn hex_to_u64_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("hex_to_u64 filter requires a string"))?;
let trimmed = s
.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.unwrap_or(s);
let n = u64::from_str_radix(trimmed, 16)
.map_err(|e| tera::Error::msg(format!("hex_to_u64: {s:?} is not valid hex: {e}")))?;
Ok(Value::Number(n.into()))
}
#[allow(clippy::unnecessary_wraps)]
fn snake_case_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("snake_case filter requires a string"))?;
Ok(Value::String(s.to_snake_case()))
}
#[allow(clippy::unnecessary_wraps)]
fn pascal_case_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("pascal_case filter requires a string"))?;
Ok(Value::String(s.to_pascal_case()))
}
#[allow(clippy::unnecessary_wraps)]
fn camel_case_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("camel_case filter requires a string"))?;
Ok(Value::String(s.to_camel_case()))
}
#[allow(clippy::unnecessary_wraps)]
fn kebab_case_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("kebab_case filter requires a string"))?;
Ok(Value::String(s.to_kebab_case()))
}
#[allow(clippy::unnecessary_wraps)]
fn shouty_snake_case_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("shouty_snake_case filter requires a string"))?;
Ok(Value::String(s.to_screaming_snake_case()))
}
#[allow(clippy::unnecessary_wraps)]
fn title_case_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("title_case filter requires a string"))?;
Ok(Value::String(s.to_title_case()))
}
#[allow(clippy::unnecessary_wraps)]
fn pluralize_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("pluralize filter requires a string"))?;
Ok(Value::String(s.to_plural()))
}
#[allow(clippy::unnecessary_wraps)]
fn singularize_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("singularize filter requires a string"))?;
Ok(Value::String(s.to_singular()))
}
#[allow(clippy::unnecessary_wraps)]
fn ordinalize_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("ordinalize filter requires a string"))?;
Ok(Value::String(s.ordinalize()))
}
#[allow(clippy::unnecessary_wraps)]
fn demodulize_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("demodulize filter requires a string"))?;
Ok(Value::String(s.demodulize().clone()))
}
#[allow(clippy::unnecessary_wraps)]
fn foreign_key_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("foreign_key filter requires a string"))?;
Ok(Value::String(s.to_foreign_key()))
}
#[allow(clippy::unnecessary_wraps)]
fn tableize_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("tableize filter requires a string"))?;
Ok(Value::String(s.to_table_case()))
}
#[allow(clippy::unnecessary_wraps)]
fn classify_filter(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
let s = value
.as_str()
.ok_or_else(|| tera::Error::msg("classify filter requires a string"))?;
Ok(Value::String(s.to_class_case()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
const TTL: &str = r#"
@prefix ex: <http://example.org/> .
ex:alice ex:name "alice_smith" .
ex:bob ex:name "bob_jones" .
"#;
fn graph() -> Arc<dyn GraphEngine> {
let g = crate::graph::DeterministicGraph::new().expect("graph");
g.insert_turtle(TTL).expect("ttl");
Arc::new(g)
}
#[test]
fn sparql_row_keys_are_bare_not_question_mark_prefixed() {
let value = sparql_to_value(
graph().as_ref(),
"SELECT ?name WHERE { ?s <http://example.org/name> ?name } ORDER BY ?name",
)
.expect("query");
let rows = value.as_array().expect("array");
let first = rows[0].as_object().expect("object");
assert!(
first.contains_key("name"),
"row must have bare key `name`: {first:?}"
);
assert!(
!first.contains_key("?name"),
"row must NOT have `?`-prefixed key `?name`: {first:?}"
);
}
#[test]
fn parse_and_render_with_sparql_against_graph() {
let content = "---\nto: out.rs\nsparql:\n people: SELECT ?name WHERE { ?s <http://example.org/name> ?name } ORDER BY ?name\n---\n{% for row in sparql(query=q) %}{{ row.name | pascal_case }};{% endfor %}";
let tpl = Template::parse(content).expect("parse");
assert_eq!(tpl.frontmatter.to, "out.rs");
let q = tpl.frontmatter.sparql.get("people").expect("query").clone();
let mut tera = build_tera(graph()).expect("build tera");
let mut ctx = tera::Context::new();
ctx.insert("q", &q);
let rendered = tera.render_str(&tpl.body, &ctx).expect("render");
assert_eq!(rendered, "AliceSmith;BobJones;");
}
#[test]
fn ask_query_returns_bool() {
let tera = build_tera(graph()).expect("build tera");
let mut t = tera;
let rendered = t
.render_str(
"{% if sparql(query=\"ASK { ?s ?p ?o }\") %}yes{% else %}no{% endif %}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(rendered, "yes");
}
#[test]
fn construct_returns_triples() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{% set ts = sparql(query=\"CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }\") %}{{ ts | length }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(rendered, "2");
}
#[test]
fn unknown_frontmatter_key_is_err() {
let content = "---\nto: out.rs\nvars:\n x: 1\n---\nbody";
let err = Template::parse(content).expect_err("must reject `vars:`");
assert!(err.to_string().contains("FM-TPL-002"), "{err}");
}
#[test]
fn missing_frontmatter_is_err() {
let err = Template::parse("no frontmatter here").expect_err("must reject");
assert!(err.to_string().contains("FM-TPL-001"), "{err}");
}
#[test]
fn snake_and_pascal_filters() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{{ \"FooBarBaz\" | snake_case }} {{ \"foo_bar-baz qux\" | pascal_case }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(rendered, "foo_bar_baz FooBarBazQux");
}
#[test]
fn camel_kebab_shouty_title_filters() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{{ \"foo_bar-baz\" | camel_case }} \
{{ \"FooBarBaz\" | kebab_case }} \
{{ \"foo-bar baz\" | shouty_snake_case }} \
{{ \"foo_bar-baz\" | title_case }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(rendered, "fooBarBaz foo-bar-baz FOO_BAR_BAZ Foo Bar Baz");
}
#[test]
fn pluralize_and_singularize_filters() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{{ \"cat\" | pluralize }} {{ \"city\" | pluralize }} {{ \"bus\" | pluralize }} \
{{ \"cats\" | singularize }} {{ \"cities\" | singularize }} {{ \"buses\" | singularize }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(rendered, "cats cities buses cat city bus");
}
#[test]
fn pluralize_singularize_handle_some_irregulars_via_inflector() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{{ \"goose\" | pluralize }} {{ \"man\" | pluralize }} \
{{ \"woman\" | pluralize }} {{ \"tooth\" | pluralize }} \
{{ \"foot\" | pluralize }} \
{{ \"mice\" | singularize }} {{ \"geese\" | singularize }} \
{{ \"men\" | singularize }} {{ \"women\" | singularize }} \
{{ \"teeth\" | singularize }} {{ \"feet\" | singularize }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(
rendered,
"geese men women teeth feet mouse goose man woman tooth foot"
);
}
#[test]
fn pluralize_singularize_known_inflector_gaps_are_pinned_not_silent() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{{ \"person\" | pluralize }} {{ \"children\" | singularize }} \
{{ \"people\" | singularize }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(rendered, "personople children people");
}
#[test]
fn true_inflection_filters_ordinalize_demodulize_foreign_key_tableize_classify() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{{ \"1\" | ordinalize }} {{ \"2\" | ordinalize }} {{ \"11\" | ordinalize }} \
{{ \"Foo::Bar::Baz\" | demodulize }} \
{{ \"Message\" | foreign_key }} \
{{ \"RawScaledScorer\" | tableize }} \
{{ \"line_items\" | classify }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(
rendered,
"1st 2nd 11th Baz message_id raw_scaled_scorers LineItem"
);
}
#[test]
fn local_function_strips_namespace() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{{ local(iri=\"http://example.org/name\") }} \
{{ local(iri=\"http://example.org/ns#Widget\") }} \
{{ local(iri=\"plain\") }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(rendered, "name Widget plain");
}
#[test]
fn sparql_first_values_empty_count_functions() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{% set rows = sparql(query=\"SELECT ?name WHERE { ?s <http://example.org/name> ?name } ORDER BY ?name\") %}\
{% set first = sparql_first(rows=rows) %}\
{{ first.name }};\
{{ sparql_values(rows=rows, column=\"name\") | join(sep=\",\") }};\
{{ sparql_count(rows=rows) }};\
{{ sparql_empty(rows=rows) }};\
{{ sparql_empty(rows=[]) }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(rendered, "alice_smith;alice_smith,bob_jones;2;false;true");
}
#[test]
fn sparql_first_on_empty_rows_is_null() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str("{{ sparql_first(rows=[]) }}", &tera::Context::new())
.expect("render");
assert_eq!(
rendered, "",
"Tera renders a Value::Null function result as empty output"
);
}
#[test]
fn filter_aliases_match_full_names_byte_identical() {
let mut tera = build_tera(graph()).expect("build tera");
for s in ["hello_world_example", "HelloWorldExample"] {
let mut ctx = tera::Context::new();
ctx.insert("v", s);
let rendered = tera
.render_str(
"{{ v | snake }}|{{ v | snake_case }}|\
{{ v | pascal }}|{{ v | pascal_case }}|\
{{ v | camel }}|{{ v | camel_case }}|\
{{ v | kebab }}|{{ v | kebab_case }}|\
{{ v | title }}|{{ v | title_case }}|\
{{ v | shouty }}|{{ v | shouty_snake_case }}",
&ctx,
)
.expect("render");
let parts: Vec<&str> = rendered.split('|').collect();
assert_eq!(parts.len(), 12, "unexpected render shape: {rendered:?}");
assert_eq!(parts[0], parts[1], "snake alias mismatch for {s}");
assert_eq!(parts[2], parts[3], "pascal alias mismatch for {s}");
assert_eq!(parts[4], parts[5], "camel alias mismatch for {s}");
assert_eq!(parts[6], parts[7], "kebab alias mismatch for {s}");
assert_eq!(parts[8], parts[9], "title alias mismatch for {s}");
assert_eq!(parts[10], parts[11], "shouty alias mismatch for {s}");
}
}
#[test]
fn sparql_first_values_accept_results_alias_and_column_extraction() {
let mut tera = build_tera(graph()).expect("build tera");
let rendered = tera
.render_str(
"{% set rows = sparql(query=\"SELECT ?name WHERE { ?s <http://example.org/name> ?name } ORDER BY ?name\") %}\
{% set first_full = sparql_first(results=rows) %}\
{{ first_full.name }};\
{{ sparql_first(results=rows, column=\"name\") }};\
{{ sparql_values(results=rows, column=\"name\") | join(sep=\",\") }};\
{{ sparql_empty(results=rows) }};\
{{ sparql_count(results=rows) }}",
&tera::Context::new(),
)
.expect("render");
assert_eq!(
rendered, "alice_smith;alice_smith;alice_smith,bob_jones;false;2",
"results= must behave identically to rows=, and results=+column= \
must extract the scalar/list for that column"
);
}
#[test]
fn sparql_functions_reject_both_rows_and_results() {
let mut tera = build_tera(graph()).expect("build tera");
for call in [
"sparql_first(rows=[], results=[])",
"sparql_values(rows=[], results=[], column=\"x\")",
"sparql_empty(rows=[], results=[])",
"sparql_count(rows=[], results=[])",
] {
let err = tera
.render_str(&format!("{{{{ {call} }}}}"), &tera::Context::new())
.unwrap_err();
assert!(
format!("{err:?}").contains("not both"),
"{call} must refuse rows=+results= loudly: {err:?}"
);
}
}
#[test]
fn sparql_field_accepts_bare_string_and_sequence_forms() {
const QUERY: &str =
"SELECT ?name WHERE { ?s <http://example.org/name> ?name } ORDER BY ?name";
let map_tpl = Template::parse(&format!(
"---\nto: out.rs\nsparql:\n people: {QUERY}\n---\nbody"
))
.expect("map form parses");
let bare_tpl = Template::parse(&format!("---\nto: out.rs\nsparql: {QUERY}\n---\nbody"))
.expect("bare form parses");
let seq_tpl = Template::parse(&format!("---\nto: out.rs\nsparql:\n - {QUERY}\n---\nbody"))
.expect("sequence form parses");
assert_eq!(
map_tpl.frontmatter.sparql.get("people").map(String::as_str),
Some(QUERY)
);
assert_eq!(
bare_tpl
.frontmatter
.sparql
.get("default")
.map(String::as_str),
Some(QUERY),
"bare string must be named `default`, matching ggen-core's convention"
);
assert_eq!(
seq_tpl.frontmatter.sparql.get("query_0").map(String::as_str),
Some(QUERY),
"sequence entries must be named `query_0`, `query_1`, …, matching ggen-core's convention"
);
}
#[test]
fn bare_string_sparql_field_is_queryable_same_as_map_form() {
let content = "---\nto: out.rs\nsparql: SELECT ?name WHERE { ?s <http://example.org/name> ?name } ORDER BY ?name\n---\n{% for row in sparql(query=q) %}{{ row.name | pascal_case }};{% endfor %}";
let tpl = Template::parse(content).expect("parse");
let q = tpl
.frontmatter
.sparql
.get("default")
.expect("default query")
.clone();
let mut tera = build_tera(graph()).expect("build tera");
let mut ctx = tera::Context::new();
ctx.insert("q", &q);
let rendered = tera.render_str(&tpl.body, &ctx).expect("render");
assert_eq!(
rendered, "AliceSmith;BobJones;",
"identical to the mapping-form test `parse_and_render_with_sparql_against_graph`"
);
}
}