rgen-core 0.1.0

Core graph-aware code generation engine
Documentation
//! Hygen-like POC with RDF support, prefixes, and inline RDF.
//! - gray_matter: YAML frontmatter
//! - Tera: full {{ }} in header and body (includes/macros supported)
//! - Oxigraph: RDF load + `sparql(query=..., var=...)` with auto PREFIX/BASE
//! - Inline Turtle via `rdf_inline:`
//! - Prefix prolog via `prefixes:`
//! - Utility `local(iri=...)` to get local name
//!
//! Frontmatter example:
//! ---
//! to: "src/{{ name | lower }}.rs"
//! 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}} ({{ local(iri=slug) }}) | {{license}}

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>, // file paths relative to template dir
    #[serde(default)]
    pub rdf_inline: Vec<String>, // inline Turtle strings
    #[serde(default)]
    pub prefixes: BTreeMap<String, String>, // e.g., { ex: "http://example/" }
    #[serde(default)]
    pub base: Option<String>, // BASE IRI
}

#[derive(Clone)]
struct SparqlFn {
    store: Store,
    prolog: String, // PREFIX/BASE prelude
    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();
        // Strip <...> if present, then take after last '#' or '/'
        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> {
    // Tera env rooted at template directory (enables includes/macros)
    let tpl_root = template_path.parent().unwrap_or_else(|| Path::new("."));
    let mut tera = build_tera(tpl_root)?;

    // Split frontmatter + body (gray-matter)
    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"))?;

    // Render frontmatter via Tera using CLI vars only
    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}")))?;

    // Deserialize → merge defaults + CLI
    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);

    // Load RDF graph: files + inline
    let store = load_rdf(&fm.rdf, &fm.rdf_inline, tpl_root)?;

    // Register SPARQL + local() functions
    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);

    // Resolve output path
    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);

    // Render body
    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)
}

/* ---------------- helpers ---------------- */

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![]); // disable auto-escape for codegen
    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");

    // Files
    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())))?;
        }
    }

    // Inline Turtle
    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();
        // In dry-run mode, file should not exist but we can still verify the path
        assert!(!out.exists());
        // The test still validates that the template processing works correctly
    }
}