use crate::error::AppError;
use crate::graph::{GraphEngine as _, GraphLawStore};
use crate::template::Frontmatter;
const FRONTMATTER_SCHEMA_TTL: &str = include_str!("../schema/frontmatter-schema.ttl");
pub const FM_NS: &str = "https://praxis.dev/ggen/frontmatter#";
fn escape_turtle_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn project_frontmatter(fm: &Frontmatter) -> crate::error::Result<String> {
use std::fmt::Write as _;
let mut ttl = String::new();
let _ = writeln!(ttl, "@prefix fm: <{FM_NS}> .");
let _ = writeln!(ttl, "@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .");
let _ = writeln!(ttl, "_:frontmatter a fm:Frontmatter ;");
let _ = write!(ttl, " fm:to \"{}\"", escape_turtle_string(&fm.to));
if let Some(at_line) = fm.at_line {
let _ = write!(ttl, " ;\n fm:atLine {at_line}");
}
if let Some(sh_before) = &fm.sh_before {
let _ = write!(
ttl,
" ;\n fm:shBefore \"{}\"",
escape_turtle_string(sh_before)
);
}
if let Some(sh_after) = &fm.sh_after {
let _ = write!(
ttl,
" ;\n fm:shAfter \"{}\"",
escape_turtle_string(sh_after)
);
}
if let Some(when) = &fm.when {
let _ = write!(ttl, " ;\n fm:when \"{}\"", escape_turtle_string(when));
}
if let Some(from) = &fm.from {
let _ = write!(ttl, " ;\n fm:from \"{}\"", escape_turtle_string(from));
}
if let Some(base) = &fm.base {
let _ = write!(ttl, " ;\n fm:base \"{}\"", escape_turtle_string(base));
}
ttl.push_str(" .\n");
Ok(ttl)
}
pub fn validate_frontmatter_shacl(fm: &Frontmatter) -> crate::error::Result<()> {
let ttl = project_frontmatter(fm)?;
let engine = GraphLawStore::new()?;
engine.insert_turtle(&ttl).map_err(|e| {
AppError::fm_tpl(
23,
format!("projected frontmatter facts failed to load into a fresh graph: {e}"),
)
})?;
let outcome = engine.validate_shacl(FRONTMATTER_SCHEMA_TTL).map_err(|e| {
AppError::fm_tpl(
23,
format!(
"frontmatter SHACL validation could not run: {e}. Remediation: use the \
default GraphLaw engine (no `--engine oxigraph`), which is the only \
engine with SHACL support."
),
)
})?;
if !outcome.conforms {
return Err(AppError::fm_tpl(
23,
format!(
"frontmatter block failed SHACL validation, {} violation(s): {}. \
Remediation: fix the listed field(s) in the template's frontmatter block.",
outcome.violations.len(),
outcome.violations.join("; ")
),
));
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::expect_fun_call)]
mod tests {
use super::*;
fn minimal_frontmatter(to: &str) -> Frontmatter {
let yaml = format!("to: \"{to}\"\n");
serde_yaml::from_str(&yaml).expect("minimal frontmatter parses")
}
#[test]
fn projects_required_to_field() {
let fm = minimal_frontmatter("src/{{ name }}.rs");
let ttl = project_frontmatter(&fm).expect("projection");
assert!(ttl.contains("fm:Frontmatter"));
assert!(ttl.contains("fm:to \"src/{{ name }}.rs\""));
assert!(!ttl.contains("fm:atLine"));
assert!(!ttl.contains("fm:shBefore"));
assert!(!ttl.contains("fm:shAfter"));
}
#[test]
fn projects_at_line_and_sh_hooks_when_present() {
let yaml =
"to: \"out.rs\"\nat_line: 5\nsh_before: \"echo before\"\nsh_after: \"echo after\"\n";
let fm: Frontmatter = serde_yaml::from_str(yaml).expect("frontmatter parses");
let ttl = project_frontmatter(&fm).expect("projection");
assert!(ttl.contains("fm:atLine 5"));
assert!(ttl.contains("fm:shBefore \"echo before\""));
assert!(ttl.contains("fm:shAfter \"echo after\""));
}
#[test]
fn projects_when_from_and_base_when_present() {
let yaml = "to: \"out.rs\"\nwhen: \"ASK { ?s a <http://example.org/Dog> }\"\nfrom: \"other.tmpl\"\nbase: \"http://example.org/\"\n";
let fm: Frontmatter = serde_yaml::from_str(yaml).expect("frontmatter parses");
let ttl = project_frontmatter(&fm).expect("projection");
assert!(ttl.contains("fm:when \"ASK { ?s a <http://example.org/Dog> }\""));
assert!(ttl.contains("fm:from \"other.tmpl\""));
assert!(ttl.contains("fm:base \"http://example.org/\""));
}
#[test]
fn escapes_quotes_and_backslashes_in_string_literals() {
let yaml = "to: \"a\\\"b\\\\c\"\n";
let fm: Frontmatter = serde_yaml::from_str(yaml).expect("frontmatter parses");
let ttl = project_frontmatter(&fm).expect("projection");
assert!(ttl.contains(r#"fm:to "a\"b\\c""#));
}
#[test]
fn projected_turtle_is_valid_and_loads_into_a_real_graph() {
use crate::graph::GraphEngine as _;
let fm = minimal_frontmatter("out.rs");
let ttl = project_frontmatter(&fm).expect("projection");
let graph = crate::graph::GraphLawStore::new().expect("graphlaw store");
let inserted = graph.insert_turtle(&ttl).expect("turtle must parse");
assert!(inserted > 0, "expected at least one quad inserted");
}
#[test]
fn conforming_frontmatter_passes_real_shacl_validation() {
let yaml =
"to: \"out.rs\"\nat_line: 5\nsh_before: \"echo before\"\nsh_after: \"echo after\"\n";
let fm: Frontmatter = serde_yaml::from_str(yaml).expect("frontmatter parses");
let result = validate_frontmatter_shacl(&fm);
assert!(
result.is_ok(),
"conforming frontmatter must pass SHACL validation, got: {result:?}"
);
}
#[test]
fn at_line_zero_is_refused_by_real_shacl_validation() {
let yaml = "to: \"out.rs\"\nat_line: 0\n";
let fm: Frontmatter = serde_yaml::from_str(yaml).expect("frontmatter parses");
let result = validate_frontmatter_shacl(&fm);
let err = result.expect_err("at_line: 0 must be refused by the CP8 shape");
let msg = err.to_string();
assert!(
msg.contains("FM-TPL-023"),
"expected FM-TPL-023, got: {msg}"
);
assert!(
msg.contains("violation"),
"expected a violation message, got: {msg}"
);
}
#[test]
fn empty_sh_before_is_refused_by_real_shacl_validation() {
let yaml = "to: \"out.rs\"\nsh_before: \"\"\n";
let fm: Frontmatter = serde_yaml::from_str(yaml).expect("frontmatter parses");
let result = validate_frontmatter_shacl(&fm);
let err = result.expect_err("empty sh_before must be refused by the CP8 shape");
assert!(err.to_string().contains("FM-TPL-023"));
}
#[test]
fn empty_when_is_refused_by_real_shacl_validation() {
let yaml = "to: \"out.rs\"\nwhen: \"\"\n";
let fm: Frontmatter = serde_yaml::from_str(yaml).expect("frontmatter parses");
let result = validate_frontmatter_shacl(&fm);
let err = result.expect_err("empty when must be refused by the CP8 shape");
assert!(err.to_string().contains("FM-TPL-023"));
}
#[test]
fn empty_from_is_refused_by_real_shacl_validation() {
let yaml = "to: \"out.rs\"\nfrom: \"\"\n";
let fm: Frontmatter = serde_yaml::from_str(yaml).expect("frontmatter parses");
let result = validate_frontmatter_shacl(&fm);
let err = result.expect_err("empty from must be refused by the CP8 shape");
assert!(err.to_string().contains("FM-TPL-023"));
}
#[test]
fn empty_base_is_refused_by_real_shacl_validation() {
let yaml = "to: \"out.rs\"\nbase: \"\"\n";
let fm: Frontmatter = serde_yaml::from_str(yaml).expect("frontmatter parses");
let result = validate_frontmatter_shacl(&fm);
let err = result.expect_err("empty base must be refused by the CP8 shape");
assert!(err.to_string().contains("FM-TPL-023"));
}
#[test]
fn drift_injection_and_revert_proves_the_shape_is_not_vacuous() {
let good_yaml = "to: \"out.rs\"\nat_line: 3\n";
let good: Frontmatter = serde_yaml::from_str(good_yaml).expect("parses");
assert!(
validate_frontmatter_shacl(&good).is_ok(),
"the real, uncorrupted value must conform"
);
let mut corrupted = good.clone();
corrupted.at_line = Some(0);
assert!(
validate_frontmatter_shacl(&corrupted).is_err(),
"the deliberately corrupted value (at_line: 0) must be refused"
);
assert!(
validate_frontmatter_shacl(&good).is_ok(),
"the original value must still conform after the corrupted clone was checked"
);
}
}