use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use gray_matter::{engine::YAML, Matter, ParsedEntity};
use serde::{Deserialize, Serialize};
use tera::{Context as TeraContext, Function as TeraFunction, Tera, Value as TeraValue};
use oxigraph::io::RdfFormat;
use oxigraph::sparql::QueryResults;
use oxigraph::store::Store;
use rgen_utils::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HygenFrontmatter {
pub to: String,
#[serde(default)]
pub vars: BTreeMap<String, String>,
#[serde(default)]
pub rdf: Vec<String>, #[serde(default)]
pub rdf_inline: Vec<String>, #[serde(default)]
pub prefixes: BTreeMap<String, String>, #[serde(default)]
pub base: Option<String>, }
#[derive(Clone)]
struct SparqlFn {
store: Store,
prolog: String, trace: bool,
}
impl TeraFunction for SparqlFn {
fn call(&self, args: &std::collections::HashMap<String, TeraValue>) -> tera::Result<TeraValue> {
let q = args
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| tera::Error::msg("sparql: query required"))?;
let want = args.get("var").and_then(|v| v.as_str());
let final_q = if self.prolog.is_empty() {
q.to_string()
} else {
format!("{}\n{}", self.prolog, q)
};
if self.trace {
eprintln!("[rgen.sparql] {}", final_q.replace('\n', " "))
}
let res = self
.store
.query(&final_q)
.map_err(|e| tera::Error::msg(format!("sparql: {e}")))?;
match res {
QueryResults::Solutions(solutions) => {
let mut rows = Vec::new();
for sol in solutions {
let sol = sol.map_err(|e| tera::Error::msg(format!("sparql row: {e}")))?;
let mut row = serde_json::Map::new();
for (v, term) in sol.iter() {
row.insert(
v.as_str().to_string(),
serde_json::Value::String(term.to_string()),
);
}
rows.push(serde_json::Value::Object(row));
}
if let Some(vname) = want {
if let Some(serde_json::Value::Object(obj)) = rows.first() {
if let Some(val) = obj.get(vname) {
return Ok(val.clone());
}
}
return Ok(serde_json::Value::String(String::new()));
}
Ok(serde_json::Value::Array(rows))
}
QueryResults::Boolean(b) => Ok(serde_json::Value::Bool(b)),
QueryResults::Graph(_) => Ok(serde_json::Value::String(String::new())),
}
}
}
#[derive(Clone)]
struct LocalFn;
impl TeraFunction for LocalFn {
fn call(&self, args: &std::collections::HashMap<String, TeraValue>) -> tera::Result<TeraValue> {
let iri = args.get("iri").and_then(|v| v.as_str()).unwrap_or_default();
let s = iri.trim();
let s = s
.strip_prefix('<')
.and_then(|x| x.strip_suffix('>'))
.unwrap_or(s);
let idx = s
.rfind(['#', '/'])
.map(|i| i + 1)
.unwrap_or(0);
Ok(serde_json::Value::String(s[idx..].to_string()))
}
}
pub fn poc_hygen(
template_path: &Path, out_root: &Path, cli_vars: &BTreeMap<String, String>, dry_run: bool,
) -> Result<PathBuf> {
let tpl_root = template_path.parent().unwrap_or_else(|| Path::new("."));
let mut tera = build_tera(tpl_root)?;
let raw = fs::read_to_string(template_path)?;
let matter = Matter::<YAML>::new();
let ParsedEntity { data, content, .. } = matter
.parse::<serde_yaml::Value>(&raw)
.map_err(|e| Error::new(&format!("gray-matter: {e}")))?;
let yaml_value = data.ok_or_else(|| Error::new("missing YAML frontmatter"))?;
let yaml_src = serde_yaml::to_string(&yaml_value)?;
tera.add_raw_template("__fm__", &yaml_src)
.map_err(|e| Error::new(&format!("tera add header: {e}")))?;
let rendered_yaml = tera
.render("__fm__", &tera_context(cli_vars))
.map_err(|e| Error::new(&format!("tera header: {e}")))?;
let fm: HygenFrontmatter = serde_yaml::from_str(&rendered_yaml)
.map_err(|e| Error::new(&format!("frontmatter YAML: {e}")))?;
if fm.to.trim().is_empty() {
return Err(Error::new("frontmatter `to` required"));
}
let vars = merged_ctx(&fm.vars, cli_vars);
let store = load_rdf(&fm.rdf, &fm.rdf_inline, tpl_root)?;
let prolog = build_prolog(&fm.prefixes, fm.base.as_deref());
let trace = std::env::var_os("RGEN_TRACE").is_some();
tera.register_function(
"sparql",
SparqlFn {
store: store.clone(),
prolog,
trace,
},
);
tera.register_function("local", LocalFn);
tera.add_raw_template("__to__", &fm.to)
.map_err(|e| Error::new(&format!("tera add to: {e}")))?;
let rel_out = tera
.render("__to__", &tera_context(&vars))
.map_err(|e| Error::new(&format!("tera to: {e}")))?;
let out_path = out_root.join(rel_out);
let virtual_name = virtual_name_for(template_path);
tera.add_raw_template(&virtual_name, &content)
.map_err(|e| Error::new(&format!("tera add body: {e}")))?;
let rendered = tera
.render(&virtual_name, &tera_context(&vars))
.map_err(|e| Error::new(&format!("tera body: {e}")))?;
if !dry_run {
ensure_parent_dirs(&out_path)?;
fs::write(&out_path, rendered.as_bytes())?;
}
Ok(out_path)
}
fn build_tera(root: &Path) -> Result<Tera> {
let glob = format!("{}/**/*", root.display());
let mut tera = Tera::new(&glob).unwrap_or_default();
tera.autoescape_on(vec![]); Ok(tera)
}
fn build_prolog(prefixes: &BTreeMap<String, String>, base: Option<&str>) -> String {
let mut s = String::new();
if let Some(b) = base {
use std::fmt::Write;
let _ = writeln!(s, "BASE <{}>", b);
}
for (pfx, iri) in prefixes {
use std::fmt::Write;
let _ = writeln!(s, "PREFIX {}: <{}>", pfx, iri);
}
s
}
fn load_rdf(rdf_paths: &[String], rdf_inline: &[String], base: &Path) -> Result<Store> {
let store = Store::new().expect("oxigraph store");
if !rdf_paths.is_empty() {
let mut paths: Vec<PathBuf> = rdf_paths.iter().map(|p| base.join(p)).collect();
paths.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
for p in paths {
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("ttl");
let fmt = match ext {
"ttl" | "turtle" => RdfFormat::Turtle,
"nt" | "ntriples" => RdfFormat::NTriples,
"rdf" | "xml" => RdfFormat::RdfXml,
_ => return Err(Error::new(&format!("unsupported RDF format: {}", ext))),
};
let f = std::fs::File::open(&p)?;
store
.load_from_reader(fmt, std::io::BufReader::new(f))
.map_err(|e| Error::new(&format!("load RDF {}: {e}", p.display())))?;
}
}
for ttl in rdf_inline {
store
.load_from_reader(RdfFormat::Turtle, std::io::Cursor::new(ttl))
.map_err(|e| Error::new(&format!("load inline RDF: {e}")))?;
}
Ok(store)
}
fn merged_ctx(
defaults: &BTreeMap<String, String>, cli: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut out = defaults.clone();
for (k, v) in cli {
out.insert(k.clone(), v.clone());
}
out
}
fn tera_context(vars: &BTreeMap<String, String>) -> TeraContext {
let mut ctx = TeraContext::new();
for (k, v) in vars {
ctx.insert(k, v);
}
ctx.insert(
"env",
&std::env::vars().collect::<BTreeMap<String, String>>(),
);
ctx.insert(
"cwd",
&std::env::current_dir().unwrap().display().to_string(),
);
ctx
}
fn ensure_parent_dirs(p: &Path) -> Result<()> {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(())
}
fn virtual_name_for(p: &Path) -> String {
p.file_name()
.and_then(|s| s.to_str())
.unwrap_or("__body__")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn poc_with_prefixes_and_inline_rdf() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let tmpl = root.join("sample.tmpl");
fs::write(
&tmpl,
r#"---
to: "out/{{ name | lower }}.txt"
prefixes: { ex: "http://example/" }
base: "http://example/"
rdf_inline:
- "@prefix ex: <http://example/> . ex:x a ex:Type ."
vars:
license: "MIT"
---
{% set slug = sparql(query="SELECT ?s WHERE { ?s a ex:Type }", var="s") %}
{{name}} :: {{license}} :: {{ local(iri=slug) }}
"#,
)
.unwrap();
let mut vars = BTreeMap::new();
vars.insert("name".into(), "WidgetX".into());
let out = poc_hygen(&tmpl, root, &vars, true).unwrap();
assert!(!out.exists());
}
}