use crate::graph::{build_prolog, Graph};
use crate::register;
use crate::simple_tracing::SimpleTracer;
use crate::template::Frontmatter;
use anyhow::Result;
use oxigraph::sparql::QueryResults;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use tera::{Context, Function as TeraFunction, Tera};
pub struct Pipeline {
pub(crate) tera: Tera,
pub(crate) graph: Graph,
}
impl Pipeline {
pub fn new() -> Result<Self> {
let mut tera = Tera::default();
tera.autoescape_on(vec![]);
register::register_all(&mut tera);
Ok(Self {
tera,
graph: Graph::new()?,
})
}
pub fn tera_mut(&mut self) -> &mut Tera {
&mut self.tera
}
pub fn register_prefixes(&mut self, base: Option<&str>, prefixes: &BTreeMap<String, String>) {
let prolog = build_prolog(prefixes, base);
self.tera.register_function(
"sparql",
SparqlFn {
graph: self.graph.clone(),
prolog,
},
);
self.tera.register_function("local", LocalFn);
}
pub fn render_body(&mut self, body: &str, ctx: &Context) -> Result<String> {
Ok(self.tera.render_str(body, ctx)?)
}
pub fn render_file(
&mut self, template_path: &Path, vars: &BTreeMap<String, String>, dry_run: bool,
) -> Result<Plan> {
let input = std::fs::read_to_string(template_path)?;
let _body_lines = input.lines().count();
let mut ctx = Context::from_serialize(vars)?;
let mut template = crate::template::Template::parse(&input)?;
template.render_frontmatter(&mut self.tera, &ctx)?;
crate::register::bless_context(&mut ctx);
for (k, v) in &template.front.vars {
ctx.insert(k, v);
}
if SimpleTracer::is_enabled() {
SimpleTracer::trace(
crate::simple_tracing::TraceLevel::Debug,
&format!(
"Registered {} template-defined variables",
template.front.vars.len()
),
Some("context"),
);
}
let out_path = if let Some(to_path) = &template.front.to {
let rendered_to = self.tera.render_str(to_path, &ctx)?;
PathBuf::from(rendered_to)
} else {
PathBuf::from("out.txt")
};
SimpleTracer::rdf_loading(
&template.front.rdf,
template.front.rdf_inline.len(),
self.graph.len(),
);
template.process_graph(&mut self.graph, &mut self.tera, &ctx, template_path)?;
SimpleTracer::rdf_loading(
&template.front.rdf,
template.front.rdf_inline.len(),
self.graph.len(),
);
let rendered = template.render(&mut self.tera, &ctx)?;
SimpleTracer::template_complete(template_path, &out_path, rendered.len());
let plan = Plan {
template_path: template_path.to_path_buf(),
output_path: out_path,
content: rendered,
frontmatter: template.front,
dry_run,
};
if dry_run {
}
Ok(plan)
}
}
pub struct PipelineBuilder {
prefixes: BTreeMap<String, String>,
base: Option<String>,
preload_ttl_files: Vec<String>,
preload_ttl_inline: Vec<String>,
}
impl Default for PipelineBuilder {
fn default() -> Self {
Self::new()
}
}
impl PipelineBuilder {
pub fn new() -> Self {
Self {
prefixes: BTreeMap::new(),
base: None,
preload_ttl_files: vec![],
preload_ttl_inline: vec![],
}
}
pub fn with_prefixes(mut self, pfx: BTreeMap<String, String>, base: Option<String>) -> Self {
self.prefixes = pfx;
self.base = base;
self
}
pub fn with_rdf_files<S: Into<String>>(mut self, files: impl IntoIterator<Item = S>) -> Self {
self.preload_ttl_files = files.into_iter().map(Into::into).collect();
self
}
pub fn with_rdf_file<S: Into<String>>(mut self, file: S) -> Self {
self.preload_ttl_files.push(file.into());
self
}
pub fn with_inline_rdf<S: Into<String>>(mut self, blocks: impl IntoIterator<Item = S>) -> Self {
self.preload_ttl_inline = blocks.into_iter().map(Into::into).collect();
self
}
pub fn build(self) -> Result<Pipeline> {
let mut p = Pipeline::new()?;
for f in &self.preload_ttl_files {
let ttl = std::fs::read_to_string(f)?;
p.graph.insert_turtle(&ttl)?;
}
for ttl in &self.preload_ttl_inline {
p.graph.insert_turtle(ttl)?;
}
p.register_prefixes(self.base.as_deref(), &self.prefixes);
Ok(p)
}
}
#[derive(Clone)]
struct SparqlFn {
graph: Graph,
prolog: String,
}
impl TeraFunction for SparqlFn {
fn call(&self, args: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
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)
};
SimpleTracer::sparql_query(&final_q, None);
let results = self
.graph
.query(&final_q)
.map_err(|e| tera::Error::msg(e.to_string()))?;
let json_val = match results {
QueryResults::Boolean(b) => serde_json::Value::Bool(b),
QueryResults::Solutions(solutions) => {
let mut rows = Vec::new();
for solution in solutions {
let solution = solution.map_err(|e| tera::Error::msg(e.to_string()))?;
let mut row = serde_json::Map::new();
for (var, term) in solution.iter() {
row.insert(var.to_string(), serde_json::Value::String(term.to_string()));
}
rows.push(serde_json::Value::Object(row));
}
serde_json::Value::Array(rows)
}
QueryResults::Graph(_) => serde_json::Value::Array(Vec::new()), };
if let Some(var_name) = want {
if let serde_json::Value::Array(rows) = &json_val {
if let Some(serde_json::Value::Object(obj)) = rows.first() {
if let Some(val) = obj.get(var_name) {
return Ok(val.clone());
}
}
}
return Ok(serde_json::Value::String(String::new()));
}
Ok(json_val)
}
}
#[derive(Clone)]
struct LocalFn;
impl TeraFunction for LocalFn {
fn call(&self, args: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
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()))
}
}
#[allow(dead_code)]
pub struct Plan {
template_path: PathBuf,
output_path: PathBuf,
content: String,
frontmatter: Frontmatter,
dry_run: bool,
}
impl Plan {
pub fn apply(&self) -> Result<()> {
if self.dry_run {
SimpleTracer::dry_run(&self.output_path, self.content.len());
return Ok(());
}
if self.frontmatter.inject {
let _mode = "injection"; let result = self.apply_injection();
return result;
}
self.apply_regular()
}
fn apply_injection(&self) -> Result<()> {
use crate::inject::{EolNormalizer, SkipIfGenerator};
if !self.output_path.exists() {
if let Some(parent) = self.output_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.output_path, &self.content)?;
return Ok(());
}
let existing_content = std::fs::read_to_string(&self.output_path)?;
if let Some(skip_if) = &self.frontmatter.skip_if {
if regex::Regex::new(skip_if)?.is_match(&existing_content) {
return Ok(());
}
}
if self.frontmatter.idempotent
&& SkipIfGenerator::content_exists_in_file(&self.content, &self.output_path)? {
return Ok(());
}
if self.frontmatter.backup.unwrap_or(false) {
let backup_path = format!("{}.backup", self.output_path.display());
std::fs::copy(&self.output_path, &backup_path)?;
}
let normalized_content =
EolNormalizer::normalize_to_match_file(&self.content, &self.output_path)?;
let new_content = self.inject_content(&existing_content, &normalized_content)?;
if let Some(sh_before) = &self.frontmatter.sh_before {
self.execute_shell_hook(sh_before, "before")?;
}
std::fs::write(&self.output_path, new_content)?;
println!("Injected: {}", self.output_path.display());
if let Some(sh_after) = &self.frontmatter.sh_after {
self.execute_shell_hook(sh_after, "after")?;
}
Ok(())
}
fn apply_regular(&self) -> Result<()> {
if self.frontmatter.unless_exists && self.output_path.exists() {
println!("Skipped: file already exists and unless_exists=true");
return Ok(());
}
if !self.frontmatter.force && self.output_path.exists() {
return Err(anyhow::anyhow!(
"File already exists: {}. Use --force to overwrite.",
self.output_path.display()
));
}
if let Some(parent) = self.output_path.parent() {
std::fs::create_dir_all(parent)?;
}
if let Some(sh_before) = &self.frontmatter.sh_before {
self.execute_shell_hook(sh_before, "before")?;
}
std::fs::write(&self.output_path, &self.content)?;
println!("Generated: {}", self.output_path.display());
if let Some(sh_after) = &self.frontmatter.sh_after {
self.execute_shell_hook(sh_after, "after")?;
}
Ok(())
}
fn inject_content(&self, existing: &str, new_content: &str) -> Result<String> {
let lines: Vec<&str> = existing.lines().collect();
let new_lines: Vec<&str> = new_content.lines().collect();
match (
self.frontmatter.prepend,
self.frontmatter.append,
&self.frontmatter.before,
&self.frontmatter.after,
self.frontmatter.at_line,
) {
(true, _, _, _, _) => {
let mut result = new_lines;
result.extend(lines);
Ok(result.join("\n"))
}
(_, true, _, _, _) => {
let mut result = lines;
result.extend(new_lines);
Ok(result.join("\n"))
}
(_, _, Some(before), _, _) => {
if let Some(index) = lines.iter().position(|line| line.contains(before)) {
let mut result = lines[..index].to_vec();
result.extend(new_lines);
result.extend(lines[index..].to_vec());
Ok(result.join("\n"))
} else {
let mut result = lines;
result.extend(new_lines);
Ok(result.join("\n"))
}
}
(_, _, _, Some(after), _) => {
if let Some(index) = lines.iter().position(|line| line.contains(after)) {
let mut result = lines[..=index].to_vec();
result.extend(new_lines);
result.extend(lines[index + 1..].to_vec());
Ok(result.join("\n"))
} else {
let mut result = lines;
result.extend(new_lines);
Ok(result.join("\n"))
}
}
(_, _, _, _, Some(line_num)) => {
let index = (line_num as usize).saturating_sub(1);
let mut result = lines[..index].to_vec();
result.extend(new_lines);
result.extend(lines[index..].to_vec());
Ok(result.join("\n"))
}
_ => {
let mut result = lines;
result.extend(new_lines);
Ok(result.join("\n"))
}
}
}
fn execute_shell_hook(&self, command: &str, _timing: &str) -> Result<()> {
use std::process::Command;
let output = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(std::env::current_dir()?)
.output()?;
let _exit_code = output.status.code().unwrap_or(-1);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Shell hook failed: {}", stderr));
}
if !output.stdout.is_empty() {
let stdout = String::from_utf8_lossy(&output.stdout);
print!("{}", stdout);
}
Ok(())
}
pub fn print_diff(&self) -> Result<()> {
println!("DRY RUN - Would generate: {}", self.output_path.display());
if self.output_path.exists() {
let existing_content = std::fs::read_to_string(&self.output_path)?;
print_colorized_diff(&existing_content, &self.content, &self.output_path);
} else {
print_colorized_new_file(&self.content, &self.output_path);
}
Ok(())
}
pub fn content_hash(&self) -> Option<String> {
let mut hasher = Sha256::new();
hasher.update(self.content.as_bytes());
Some(format!("{:x}", hasher.finalize()))
}
}
fn print_colorized_diff(old: &str, new: &str, path: &Path) {
use colored::*;
println!("{} {}", "---".red(), path.display());
println!("{} {}", "+++".green(), path.display());
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let max_lines = old_lines.len().max(new_lines.len());
for i in 0..max_lines {
let old_line = old_lines.get(i);
let new_line = new_lines.get(i);
match (old_line, new_line) {
(Some(old), Some(new)) if old == new => {
println!(" {}", old);
}
(Some(old), Some(new)) => {
println!("{}{}", "-".red(), old.red());
println!("{}{}", "+".green(), new.green());
}
(Some(old), None) => {
println!("{}{}", "-".red(), old.red());
}
(None, Some(new)) => {
println!("{}{}", "+".green(), new.green());
}
(None, None) => break,
}
}
}
fn print_colorized_new_file(content: &str, path: &Path) {
use colored::*;
println!("{} /dev/null", "---".red());
println!("{} {}", "+++".green(), path.display());
for line in content.lines() {
println!("{}{}", "+".green(), line.green());
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::collections::BTreeMap;
use tempfile::TempDir;
#[test]
fn test_pipeline_new() -> Result<()> {
let pipeline = Pipeline::new()?;
assert!(!pipeline.graph.is_empty() || pipeline.graph.is_empty()); Ok(())
}
#[test]
fn test_pipeline_render_body() -> Result<()> {
let mut pipeline = Pipeline::new()?;
let mut ctx = Context::new();
ctx.insert("name", "World");
let result = pipeline.render_body("Hello {{ name }}", &ctx)?;
assert_eq!(result, "Hello World");
Ok(())
}
#[test]
fn test_pipeline_render_file_basic() -> Result<()> {
let temp_dir = TempDir::new()?;
let template_content = r#"---
to: "output.txt"
---
Hello {{ name }}"#;
let template_path = temp_dir.path().join("test.tmpl");
std::fs::write(&template_path, template_content)?;
let mut pipeline = Pipeline::new()?;
let mut vars = BTreeMap::new();
vars.insert("name".to_string(), "World".to_string());
let plan = pipeline.render_file(&template_path, &vars, false)?;
assert_eq!(plan.content, "Hello World");
assert_eq!(plan.output_path.file_name().unwrap(), "output.txt");
Ok(())
}
#[test]
fn test_plan_apply_dry_run() -> Result<()> {
let temp_dir = TempDir::new()?;
let output_path = temp_dir.path().join("output.txt");
let plan = Plan {
template_path: temp_dir.path().join("test.tmpl"),
output_path: output_path.clone(),
content: "Test content".to_string(),
frontmatter: crate::template::Frontmatter::default(),
dry_run: true,
};
plan.apply()?;
assert!(!output_path.exists());
Ok(())
}
#[test]
fn test_plan_apply_creates_file() -> Result<()> {
let temp_dir = TempDir::new()?;
let output_path = temp_dir.path().join("output.txt");
let plan = Plan {
template_path: temp_dir.path().join("test.tmpl"),
output_path: output_path.clone(),
content: "Test content".to_string(),
frontmatter: crate::template::Frontmatter::default(),
dry_run: false,
};
plan.apply()?;
assert!(output_path.exists());
let content = std::fs::read_to_string(&output_path)?;
assert_eq!(content, "Test content");
Ok(())
}
#[test]
fn test_plan_unless_exists() -> Result<()> {
let temp_dir = TempDir::new()?;
let output_path = temp_dir.path().join("output.txt");
std::fs::write(&output_path, "Original content")?;
let mut frontmatter = crate::template::Frontmatter::default();
frontmatter.unless_exists = true;
let plan = Plan {
template_path: temp_dir.path().join("test.tmpl"),
output_path: output_path.clone(),
content: "New content".to_string(),
frontmatter,
dry_run: false,
};
plan.apply()?;
let content = std::fs::read_to_string(&output_path)?;
assert_eq!(content, "Original content");
Ok(())
}
#[test]
fn test_pipeline_builder_with_prefixes() -> Result<()> {
let mut prefixes = BTreeMap::new();
prefixes.insert("ex".to_string(), "http://example.org/".to_string());
let pipeline = PipelineBuilder::new()
.with_prefixes(prefixes, Some("http://example.org/base/".to_string()))
.build()?;
assert!(!pipeline.graph.is_empty() || pipeline.graph.is_empty());
Ok(())
}
#[test]
fn test_pipeline_register_prefixes() -> Result<()> {
let mut pipeline = Pipeline::new()?;
let mut prefixes = BTreeMap::new();
prefixes.insert("ex".to_string(), "http://example.org/".to_string());
pipeline.register_prefixes(Some("http://example.org/base/"), &prefixes);
let ctx = Context::new();
let result = pipeline.render_body("{{ 1 + 1 }}", &ctx)?;
assert_eq!(result, "2");
Ok(())
}
}