use std::path::PathBuf;
use camino::Utf8PathBuf;
use clap_noun_verb::{NounVerbError, Result};
use crate::config::GgenConfig;
use crate::error::AppError;
use crate::graph::{DeterministicGraph, GraphEngine, TurtleDocument};
use crate::receipt_chain_seam as receipt_record;
use crate::sync::{sync, SyncOptions, SyncReceipt, RECEIPT_LOG_REL_PATH, RECEIPT_REL_PATH};
fn project_root() -> Result<PathBuf> {
std::env::current_dir()
.map_err(|e| NounVerbError::execution_error(format!("cannot resolve cwd: {e}")))
}
fn exec_err(e: impl std::fmt::Display) -> NounVerbError {
NounVerbError::execution_error(e.to_string())
}
#[derive(serde::Deserialize)]
struct RawPayloadProbe<'a> {
#[serde(borrow)]
payload: &'a serde_json::value::RawValue,
}
fn stored_payload_hash(receipt_doc: &str) -> Result<String> {
let probe: RawPayloadProbe<'_> = serde_json::from_str(receipt_doc)
.map_err(|e| exec_err(format!("receipt payload unreadable: {e}")))?;
Ok(blake3::hash(probe.payload.get().as_bytes())
.to_hex()
.to_string())
}
pub fn handle_sync_run(
dry_run: bool, watch: bool, consumer_mode: bool,
) -> Result<serde_json::Value> {
let root = project_root()?;
if watch {
crate::watch::watch(&root, dry_run).map_err(exec_err)?;
return Ok(serde_json::json!({ "watch": "stopped" }));
}
let report = sync(
&root,
SyncOptions {
dry_run,
consumer_mode,
..Default::default()
},
)
.map_err(exec_err)?;
serde_json::to_value(report).map_err(exec_err)
}
#[allow(clippy::needless_pass_by_value)]
pub fn handle_graph_validate(
files: Vec<Utf8PathBuf>, shapes: Vec<Utf8PathBuf>,
) -> Result<serde_json::Value> {
if !files.is_empty() {
return validate_files(&files, &shapes);
}
let root = project_root()?;
match crate::schema_dispatch::load(&root).map_err(exec_err)? {
crate::schema_dispatch::ParsedGgenToml::Frontmatter(config) => {
graph_validate_frontmatter(&root, &config)
}
crate::schema_dispatch::ParsedGgenToml::DeclarativeRules(manifest) => {
graph_validate_declarative_rules(&root, &manifest)
}
}
}
fn graph_validate_frontmatter(
root: &std::path::Path, config: &GgenConfig,
) -> Result<serde_json::Value> {
let ontology_path = root.join(&config.ontology.source);
let ttl = std::fs::read_to_string(&ontology_path).map_err(|e| {
exec_err(format!(
"ontology `{}` unreadable: {e}",
ontology_path.display()
))
})?;
let graph = DeterministicGraph::new().map_err(exec_err)?;
let quads = graph.insert_turtle(&ttl).map_err(exec_err)?;
let hash = graph.state_hash().map_err(exec_err)?;
let hash_hex = crate::sync::hex32(&hash);
let packs = crate::pack::resolve(config, root).map_err(exec_err)?;
let templates = crate::sync::discover_templates(root, config, &packs).map_err(exec_err)?;
let violations: Vec<String> = templates
.iter()
.flat_map(|(path, tpl)| crate::lint::lint_template(path, tpl))
.map(|e| e.to_string())
.collect();
if !violations.is_empty() {
return Err(exec_err(format!(
"graph validate failed: {} template violation(s): {}",
violations.len(),
violations.join("; ")
)));
}
Ok(serde_json::json!({
"quads": quads,
"hash": hash_hex,
"templates_checked": templates.len(),
}))
}
fn graph_validate_declarative_rules(
root: &std::path::Path, manifest: &ggen_config::manifest::GgenManifest,
) -> Result<serde_json::Value> {
let ontology_path = root.join(&manifest.ontology.source);
let ttl = std::fs::read_to_string(&ontology_path).map_err(|e| {
exec_err(format!(
"ontology `{}` unreadable: {e}",
ontology_path.display()
))
})?;
let mut ontology_sources = Vec::with_capacity(1 + manifest.ontology.imports.len());
ontology_sources.push((ontology_path.display().to_string(), ttl));
for import in &manifest.ontology.imports {
let import_path = root.join(import);
let import_ttl = std::fs::read_to_string(&import_path).map_err(|e| {
exec_err(format!(
"ontology import `{}` unreadable: {e}",
import_path.display()
))
})?;
ontology_sources.push((import_path.display().to_string(), import_ttl));
}
let documents: Vec<TurtleDocument<'_>> = ontology_sources
.iter()
.map(|(label, content)| TurtleDocument::new(label, content))
.collect();
let graph = DeterministicGraph::new().map_err(exec_err)?;
let receipt = graph
.insert_turtle_documents(&documents)
.map_err(exec_err)?;
let quads = receipt.inserted_quads;
let hash = graph.state_hash().map_err(exec_err)?;
Ok(serde_json::json!({
"quads": quads,
"hash": crate::sync::hex32(&hash),
"templates_checked": 0,
"note": "declarative-rules ([[generation.rules]]) schema: pack resolution and \
frontmatter template lint are not implemented for this schema yet; only \
the ontology graph (source + imports) was validated.",
}))
}
#[allow(clippy::too_many_lines)]
fn validate_files(files: &[Utf8PathBuf], shapes: &[Utf8PathBuf]) -> Result<serde_json::Value> {
use crate::graph::{GraphEngine as _, GraphLawStore};
let shapes_ttl: Option<String> = if shapes.is_empty() {
None
} else {
let mut combined = String::new();
for shape_path in shapes {
let ttl = std::fs::read_to_string(shape_path)
.map_err(|e| exec_err(format!("shapes file `{shape_path}` unreadable: {e}")))?;
combined.push_str(&ttl);
combined.push('\n');
}
Some(combined)
};
let mut ok_records: Vec<serde_json::Value> = Vec::with_capacity(files.len());
let mut bad: Vec<String> = Vec::new();
for path in files {
let ttl = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
bad.push(format!("{path}: unreadable: {e}"));
continue;
}
};
let Some(shapes_ttl) = shapes_ttl.as_deref() else {
let graph = DeterministicGraph::new().map_err(exec_err)?;
let quads = match graph.insert_turtle(&ttl) {
Ok(q) => q,
Err(e) => {
bad.push(format!("{path}: {e}"));
continue;
}
};
match graph.state_hash() {
Ok(hash) => ok_records.push(serde_json::json!({
"path": path.as_str(),
"quads": quads,
"hash": crate::sync::hex32(&hash),
})),
Err(e) => bad.push(format!("{path}: {e}")),
}
continue;
};
let engine = match GraphLawStore::new() {
Ok(e) => e,
Err(e) => {
bad.push(format!("{path}: {e}"));
continue;
}
};
let quads = match engine.insert_turtle(&ttl) {
Ok(q) => q,
Err(e) => {
bad.push(format!("{path}: {e}"));
continue;
}
};
let hash = match engine.state_hash() {
Ok(h) => h,
Err(e) => {
bad.push(format!("{path}: {e}"));
continue;
}
};
let report = match engine.validate_shacl(shapes_ttl) {
Ok(r) => r,
Err(e) => {
bad.push(format!("{path}: {e}"));
continue;
}
};
if !report.conforms {
bad.push(format!(
"{path}: SHACL validation failed, {} violation(s): {}",
report.violations.len(),
report.violations.join("; ")
));
continue;
}
ok_records.push(serde_json::json!({
"path": path.as_str(),
"quads": quads,
"hash": crate::sync::hex32(&hash),
"shapes_conform": true,
}));
}
if !bad.is_empty() {
return Err(exec_err(format!(
"graph validate failed: {} of {} file(s) invalid: {}",
bad.len(),
files.len(),
bad.join("; ")
)));
}
let mut out = serde_json::json!({
"files": ok_records,
"files_checked": files.len(),
});
if !shapes.is_empty() {
out["shapes_checked"] = serde_json::json!(shapes.len());
}
Ok(out)
}
pub fn handle_receipt_verify() -> Result<serde_json::Value> {
let root = project_root()?;
handle_receipt_verify_in(&root)
}
#[allow(clippy::too_many_lines)]
pub fn handle_receipt_verify_in(root: &std::path::Path) -> Result<serde_json::Value> {
let receipt_path = root.join(RECEIPT_REL_PATH);
let raw = std::fs::read_to_string(&receipt_path).map_err(|e| {
exec_err(format!(
"receipt `{}` unreadable: {e}",
receipt_path.display()
))
})?;
let receipt: SyncReceipt = serde_json::from_str(&raw).map_err(|e| {
exec_err(format!(
"receipt `{}` malformed: {e}",
receipt_path.display()
))
})?;
if receipt.record.version != receipt_record::RECEIPT_RECORD_VERSION {
return Err(exec_err(AppError::fm_chain(
12,
format!(
"receipt invalid: unsupported receipt schema version {} (this binary supports \
{}). Remediation: re-sync with a matching ggen version, or upgrade/downgrade \
to a binary that supports this receipt's schema version.",
receipt.record.version,
receipt_record::RECEIPT_RECORD_VERSION
),
)));
}
let payload_hash_hex = stored_payload_hash(&raw)?;
if payload_hash_hex != receipt.record.payload_hash_hex {
return Err(exec_err(AppError::fm_chain(
13,
format!(
"receipt invalid: payload hash mismatch (stored {}, recomputed {payload_hash_hex})",
receipt.record.payload_hash_hex
),
)));
}
let chain_standing = match receipt
.record
.verify_chain()
.map_err(|e| exec_err(AppError::fm_chain(14, e.to_string())))?
{
receipt_record::ChainVerification::Verified(standing) => standing,
receipt_record::ChainVerification::Mismatch { rule, recomputed } => {
let stored_chain_hash = &receipt.record.chain_hash_hex;
let recomputed_hex = crate::sync::hex32(&recomputed);
return Err(exec_err(AppError::fm_chain(
14,
format!(
"receipt invalid: chain hash mismatch (stored {stored_chain_hash}, \
recomputed {recomputed_hex} under chain rule `{}`). Remediation: the \
receipt's own record fields (payload/chain hashes, andon, object_ids, \
v2, ...) were tampered with -- restore from `receipt history`/git.",
rule.as_str()
),
)));
}
};
if chain_standing == receipt_record::ChainStanding::LegacyV2Unbound {
let log_path = root.join(RECEIPT_LOG_REL_PATH);
if let Some(first) =
crate::sync::first_declared_chain_rule_in_log(&log_path, 14).map_err(exec_err)?
{
return Err(exec_err(AppError::fm_chain(
14,
format!(
"receipt invalid: chain-rule downgrade -- the head verifies only as `{}` \
but receipt log record {first} declared a chain rule. Remediation: the \
head was re-sealed under the legacy base rule -- restore from `receipt \
history`/git.",
receipt_record::ChainStanding::LegacyV2Unbound.as_str()
),
)));
}
}
let signed = receipt.record.signature_hex.is_some();
let signature_valid = match &receipt.record.signature_hex {
None => None,
Some(sig_hex) => {
use ed25519_dalek::Verifier as _;
let verifying_key = crate::keys::resolve_verifying_key(root).map_err(exec_err)?;
let sig_bytes = hex::decode(sig_hex).map_err(|e| {
exec_err(AppError::fm_chain(
15,
format!("receipt invalid: signature_hex is not valid hex: {e}"),
))
})?;
let sig_arr: [u8; 64] = sig_bytes.try_into().map_err(|v: Vec<u8>| {
exec_err(AppError::fm_chain(
16,
format!(
"receipt invalid: signature must be 64 bytes (128 hex chars), got {} bytes",
v.len()
),
))
})?;
let signature = ed25519_dalek::Signature::from_bytes(&sig_arr);
match verifying_key.verify(receipt.record.chain_hash_hex.as_bytes(), &signature) {
Ok(()) => Some(true),
Err(_) => {
return Err(exec_err(AppError::fm_chain(
17,
"receipt invalid: signature mismatch (chain integrity OK, but the \
ed25519 signature does not verify against the resolved verifying key). \
Remediation: check GGEN_SIGNING_KEY / .ggen/keys/verifying.key match \
the key that signed this receipt, or the receipt's signature_hex field \
was tampered with.",
)));
}
}
}
};
let mut out = serde_json::json!({
"valid": true,
"chain_hash": receipt.record.chain_hash_hex,
"payload_hash": receipt.record.payload_hash_hex,
"graph_hash": receipt.payload.graph_hash,
"outputs": receipt.payload.outputs.len(),
"signed": signed,
"chain_standing": chain_standing.as_str(),
});
if let Some(v) = signature_valid {
out["signature_valid"] = serde_json::json!(v);
}
Ok(out)
}
pub fn handle_receipt_history() -> Result<serde_json::Value> {
let root = project_root()?;
handle_receipt_history_in(&root)
}
#[allow(clippy::too_many_lines)]
pub fn handle_receipt_history_in(root: &std::path::Path) -> Result<serde_json::Value> {
let log_path = root.join(RECEIPT_LOG_REL_PATH);
let raw = std::fs::read_to_string(&log_path).map_err(|e| {
exec_err(AppError::fm_chain(
5,
format!(
"receipt log `{}` unreadable: {e}. \
Remediation: run `ggen sync run` at least once.",
log_path.display()
),
))
})?;
let mut receipts: Vec<(SyncReceipt, &str)> = Vec::new();
for (idx, line) in raw.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let receipt: SyncReceipt = serde_json::from_str(line).map_err(|e| {
exec_err(AppError::fm_chain(
6,
format!("receipt log entry {idx} malformed: {e}"),
))
})?;
receipts.push((receipt, line));
}
if receipts.is_empty() {
return Err(exec_err(AppError::fm_chain(
5,
format!(
"receipt log `{}` is empty. \
Remediation: run `ggen sync run` at least once.",
log_path.display()
),
)));
}
let genesis = "0".repeat(64);
if receipts[0].0.record.prev_chain_hash_hex != genesis {
return Err(exec_err(AppError::fm_chain(
7,
"history invalid at index 0: \
first record does not chain from genesis (all-zeros prev_chain_hash_hex)",
)));
}
let mut legacy_v2_unbound = 0usize;
let mut rule_monotonicity = receipt_record::ChainRuleMonotonicity::new();
for (idx, (receipt, line)) in receipts.iter().enumerate() {
if receipt.record.version != receipt_record::RECEIPT_RECORD_VERSION {
return Err(exec_err(AppError::fm_chain(
7,
format!(
"history invalid at index {idx}: unsupported receipt schema version {} \
(this binary supports {})",
receipt.record.version,
receipt_record::RECEIPT_RECORD_VERSION
),
)));
}
let payload_hash_hex = stored_payload_hash(line)?;
if payload_hash_hex != receipt.record.payload_hash_hex {
return Err(exec_err(AppError::fm_chain(
7,
format!(
"history invalid at index {idx}: payload hash mismatch \
(stored {}, recomputed {payload_hash_hex})",
receipt.record.payload_hash_hex
),
)));
}
match receipt.record.verify_chain().map_err(|e| {
exec_err(AppError::fm_chain(
7,
format!("history invalid at index {idx}: chain recompute failed: {e}"),
))
})? {
receipt_record::ChainVerification::Verified(standing) => {
rule_monotonicity
.observe(idx, &receipt.record, standing)
.map_err(|e| {
exec_err(AppError::fm_chain(
7,
format!("history invalid at index {idx}: {e}"),
))
})?;
if standing == receipt_record::ChainStanding::LegacyV2Unbound {
legacy_v2_unbound += 1;
}
}
receipt_record::ChainVerification::Mismatch { rule, recomputed } => {
let recomputed_hex = crate::sync::hex32(&recomputed);
return Err(exec_err(AppError::fm_chain(
7,
format!(
"history invalid at index {idx}: chain hash mismatch \
(stored {}, recomputed {recomputed_hex} under chain rule `{}`)",
receipt.record.chain_hash_hex,
rule.as_str()
),
)));
}
}
if let Some((next, _)) = receipts.get(idx + 1) {
if receipt.record.chain_hash_hex != next.record.prev_chain_hash_hex {
return Err(exec_err(AppError::fm_chain(
7,
format!(
"history invalid at index {idx}: broken link \
(record {idx} chain_hash_hex {} != record {} prev_chain_hash_hex {})",
receipt.record.chain_hash_hex,
idx + 1,
next.record.prev_chain_hash_hex
),
)));
}
}
}
let last = &receipts[receipts.len() - 1].0;
let receipt_path = root.join(RECEIPT_REL_PATH);
match std::fs::read_to_string(&receipt_path) {
Ok(head_raw) => {
let head: SyncReceipt = serde_json::from_str(&head_raw).map_err(|e| {
exec_err(AppError::fm_chain(
7,
format!("receipt `{}` malformed: {e}", receipt_path.display()),
))
})?;
if head.record.chain_hash_hex != last.record.chain_hash_hex {
return Err(exec_err(AppError::fm_chain(
7,
format!(
"history invalid: `.ggen-v2/receipt.json` chain hash {} does not match \
the log tail's chain hash {} -- the log has been truncated (or \
`receipt.json` was left stale) since this head was recorded. \
Remediation: restore the missing log entries or re-run `ggen sync run`.",
head.record.chain_hash_hex, last.record.chain_hash_hex
),
)));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(exec_err(format!(
"receipt `{}` unreadable: {e}",
receipt_path.display()
)))
}
}
Ok(serde_json::json!({
"valid": true,
"records": receipts.len(),
"head_chain_hash": last.record.chain_hash_hex,
"legacy_v2_unbound_records": legacy_v2_unbound,
}))
}
fn templated_to_matches(pattern: &str, path: &str) -> bool {
let mut chunks: Vec<&str> = Vec::new();
let mut rest = pattern;
while let Some(start) = rest.find("{{") {
chunks.push(&rest[..start]);
match rest[start..].find("}}") {
Some(rel_end) => rest = &rest[start + rel_end + 2..],
None => return false,
}
}
chunks.push(rest);
let mut pos = 0usize;
for (i, chunk) in chunks.iter().enumerate() {
if chunk.is_empty() {
continue;
}
if i == 0 {
if !path.starts_with(chunk) {
return false;
}
pos = chunk.len();
} else if let Some(found) = path.get(pos..).and_then(|tail| tail.find(chunk)) {
pos += found + chunk.len();
} else {
return false;
}
}
match chunks.last() {
Some(last) if !last.is_empty() => path.ends_with(last),
_ => true,
}
}
fn orphaned_artifacts_check(
receipt: &SyncReceipt, templates: &[(std::path::PathBuf, crate::template::Template)],
) -> serde_json::Value {
let mut static_tos: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
let mut templated_tos: Vec<&str> = Vec::new();
for (_, tpl) in templates {
let to = tpl.frontmatter.to.as_str();
if to.contains("{{") {
templated_tos.push(to);
} else {
static_tos.insert(to);
}
}
let orphans: Vec<String> = receipt
.payload
.outputs
.keys()
.filter(|path| {
!static_tos.contains(path.as_str())
&& !templated_tos.iter().any(|p| templated_to_matches(p, path))
})
.cloned()
.collect();
if orphans.is_empty() {
serde_json::json!({
"status": "pass",
"detail": "every receipt output is still produced by a template",
"orphans": Vec::<String>::new(),
})
} else {
serde_json::json!({
"status": "fail",
"detail": format!(
"{} output(s) recorded in the receipt with no producing template: {}",
orphans.len(),
orphans.join(", ")
),
"orphans": orphans,
})
}
}
#[allow(clippy::too_many_lines)]
pub fn handle_doctor() -> Result<serde_json::Value> {
let root = project_root()?;
let (lockfile_check, templates_for_orphan_check): (
serde_json::Value,
Option<Vec<(PathBuf, crate::template::Template)>>,
) = match crate::schema_dispatch::load(&root).map_err(exec_err)? {
crate::schema_dispatch::ParsedGgenToml::Frontmatter(config) => {
let config = *config;
let packs = crate::pack::resolve(&config, &root).map_err(exec_err)?;
let lock_entries = crate::pack::lock_entries(&config, &packs).map_err(exec_err)?;
let lockfile_check = match crate::pack::check_lock(&root, &lock_entries) {
Ok(()) => {
let detail = if lock_entries.is_empty() {
"no packs configured".to_string()
} else {
"ggen.lock matches resolved packs".to_string()
};
serde_json::json!({ "status": "pass", "detail": detail })
}
Err(e) => serde_json::json!({ "status": "fail", "detail": e.to_string() }),
};
let templates =
crate::sync::discover_templates(&root, &config, &packs).map_err(exec_err)?;
(lockfile_check, Some(templates))
}
crate::schema_dispatch::ParsedGgenToml::DeclarativeRules(_manifest) => (
serde_json::json!({
"status": "skip",
"detail": "skipped: pack resolution is not implemented for the \
declarative-rules ([[generation.rules]]) schema yet -- \
crate::pack::resolve is typed against the frontmatter GgenConfig \
schema only. See crate::generation_rules's own documented scope gap.",
}),
None,
),
};
let lockfile_pass = lockfile_check["status"] != "fail";
let receipt_path = root.join(RECEIPT_REL_PATH);
let receipt: Option<SyncReceipt> = match std::fs::read_to_string(&receipt_path) {
Ok(raw) => Some(serde_json::from_str(&raw).map_err(|e| {
exec_err(format!(
"receipt `{}` malformed: {e}",
receipt_path.display()
))
})?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(exec_err(format!(
"receipt `{}` unreadable: {e}",
receipt_path.display()
)))
}
};
let orphaned_check = match (&receipt, &templates_for_orphan_check) {
(None, _) => serde_json::json!({
"status": "pass",
"detail": "no receipt found; nothing has been generated yet",
"orphans": Vec::<String>::new(),
}),
(Some(_), None) => serde_json::json!({
"status": "skip",
"detail": "skipped: template discovery is not implemented for the \
declarative-rules ([[generation.rules]]) schema yet.",
"orphans": Vec::<String>::new(),
}),
(Some(receipt), Some(templates)) => orphaned_artifacts_check(receipt, templates),
};
let orphaned_pass = orphaned_check["status"] != "fail";
let staleness_check = match &receipt {
None => serde_json::json!({
"status": "pass",
"detail": "no receipt found; nothing has been generated yet",
"stale": Vec::<serde_json::Value>::new(),
}),
Some(receipt) => {
let mut stale: Vec<serde_json::Value> = Vec::new();
for (path, expected_hash) in &receipt.payload.outputs {
let target = root.join(path);
match std::fs::read(&target) {
Ok(bytes) => {
let actual_hash = blake3::hash(&bytes).to_hex().to_string();
if &actual_hash != expected_hash {
stale.push(serde_json::json!({
"path": path,
"reason": "hash mismatch",
"expected_hash": expected_hash,
"actual_hash": actual_hash,
}));
}
}
Err(_) => {
stale.push(serde_json::json!({
"path": path,
"reason": "file missing",
"expected_hash": expected_hash,
}));
}
}
}
if stale.is_empty() {
serde_json::json!({
"status": "pass",
"detail": "every receipt output matches its recorded hash on disk",
"stale": Vec::<serde_json::Value>::new(),
})
} else {
let paths: Vec<&str> = stale.iter().filter_map(|s| s["path"].as_str()).collect();
serde_json::json!({
"status": "fail",
"detail": format!(
"{} receipt output(s) missing or hash-mismatched on disk: {}",
stale.len(),
paths.join(", ")
),
"stale": stale,
})
}
}
};
let staleness_pass = staleness_check["status"] == "pass";
let healthy = lockfile_pass && orphaned_pass && staleness_pass;
let report = serde_json::json!({
"healthy": healthy,
"checks": {
"lockfile_drift": lockfile_check,
"orphaned_artifacts": orphaned_check,
"receipt_staleness": staleness_check,
},
});
if healthy {
Ok(report)
} else {
let mut failing: Vec<String> = Vec::new();
if !lockfile_pass {
failing.push(format!(
"lockfile_drift: {}",
lockfile_check["detail"].as_str().unwrap_or("")
));
}
if !orphaned_pass {
failing.push(format!(
"orphaned_artifacts: {}",
orphaned_check["detail"].as_str().unwrap_or("")
));
}
if !staleness_pass {
failing.push(format!(
"receipt_staleness: {}",
staleness_check["detail"].as_str().unwrap_or("")
));
}
Err(exec_err(format!(
"doctor found {} failing check(s): {}",
failing.len(),
failing.join("; ")
)))
}
}
type LawEngineBuild = (
Vec<PathBuf>,
crate::graph::GraphLawStore,
Vec<(String, usize)>,
);
fn build_law_engine(root: &std::path::Path) -> Result<LawEngineBuild> {
use crate::graph::GraphEngine as _;
let engine = crate::graph::GraphLawStore::new().map_err(exec_err)?;
let (law_rules, law_shapes): (Vec<PathBuf>, Vec<PathBuf>) =
match crate::schema_dispatch::load(root).map_err(exec_err)? {
crate::schema_dispatch::ParsedGgenToml::Frontmatter(config) => {
let config = *config;
let ontology_path = root.join(&config.ontology.source);
let ttl = std::fs::read_to_string(&ontology_path).map_err(|e| {
exec_err(format!(
"ontology `{}` unreadable: {e}",
ontology_path.display()
))
})?;
engine.insert_turtle(&ttl).map_err(exec_err)?;
let packs = crate::pack::resolve(&config, root).map_err(exec_err)?;
for pack in &packs {
let pack_ttl = std::fs::read_to_string(&pack.ontology_path).map_err(|e| {
exec_err(format!(
"pack `{}`: ontology `{}` unreadable: {e}",
pack.name,
pack.ontology_path.display()
))
})?;
engine.insert_turtle(&pack_ttl).map_err(exec_err)?;
}
(config.law.rules, config.law.shapes)
}
crate::schema_dispatch::ParsedGgenToml::DeclarativeRules(manifest) => {
let ontology_path = root.join(&manifest.ontology.source);
let ttl = std::fs::read_to_string(&ontology_path).map_err(|e| {
exec_err(format!(
"ontology `{}` unreadable: {e}",
ontology_path.display()
))
})?;
engine.insert_turtle(&ttl).map_err(exec_err)?;
for import in &manifest.ontology.imports {
let import_path = root.join(import);
let import_ttl = std::fs::read_to_string(&import_path).map_err(|e| {
exec_err(format!(
"ontology import `{}` unreadable: {e}",
import_path.display()
))
})?;
engine.insert_turtle(&import_ttl).map_err(exec_err)?;
}
(
manifest.law.rules.clone(),
manifest.validation.shacl.clone(),
)
}
};
let mut loaded: Vec<(String, usize)> = Vec::new();
for rel in &law_rules {
let rule_path = root.join(rel);
let src = std::fs::read_to_string(&rule_path).map_err(|e| {
exec_err(format!(
"rule file `{}` unreadable: {e}",
rule_path.display()
))
})?;
let n = engine.load_rules(&src).map_err(exec_err)?;
loaded.push((rel.display().to_string(), n));
}
Ok((law_shapes, engine, loaded))
}
pub fn handle_law_load() -> Result<serde_json::Value> {
let root = project_root()?;
let (_, _, loaded) = build_law_engine(&root)?;
let files: serde_json::Map<String, serde_json::Value> = loaded
.iter()
.map(|(f, n)| (f.clone(), serde_json::json!(n)))
.collect();
Ok(serde_json::json!({
"rule_files": loaded.len(),
"rules_per_file": files,
}))
}
pub fn handle_law_validate() -> Result<serde_json::Value> {
use crate::graph::GraphEngine as _;
let root = project_root()?;
let (law_shapes, engine, _) = build_law_engine(&root)?;
let outcome = engine.materialize().map_err(exec_err)?;
let denials = engine.check_denials().map_err(exec_err)?;
if !denials.is_empty() {
return Err(exec_err(AppError::fm_law(
11,
format!(
"{} denial rule(s) violated after materialization: {}",
denials.len(),
denials.join("; ")
),
)));
}
let mut shapes_checked = 0usize;
for rel in &law_shapes {
let shape_path = root.join(rel);
let shapes_ttl = std::fs::read_to_string(&shape_path).map_err(|e| {
exec_err(format!(
"SHACL shapes file `{}` unreadable: {e}",
shape_path.display()
))
})?;
let report = engine.validate_shacl(&shapes_ttl).map_err(exec_err)?;
if !report.conforms {
return Err(exec_err(AppError::fm_law(
13,
format!(
"SHACL shapes `{}` report {} violation(s): {}",
rel.display(),
report.violations.len(),
report.violations.join("; ")
),
)));
}
shapes_checked += 1;
}
Ok(serde_json::json!({
"conforms": true,
"rules_loaded": outcome.rules_loaded,
"derived": outcome.derived.len(),
"denials": 0,
"shapes_checked": shapes_checked,
}))
}
pub fn handle_law_derive() -> Result<serde_json::Value> {
use crate::graph::GraphEngine as _;
let root = project_root()?;
let (_, engine, _) = build_law_engine(&root)?;
let outcome = engine.materialize().map_err(exec_err)?;
let hash = engine.state_hash().map_err(exec_err)?;
Ok(serde_json::json!({
"rules_loaded": outcome.rules_loaded,
"derived": outcome.derived.len(),
"graph_hash": crate::sync::hex32(&hash),
}))
}
pub fn handle_law_explain() -> Result<serde_json::Value> {
use crate::graph::GraphEngine as _;
let root = project_root()?;
let (_, engine, loaded) = build_law_engine(&root)?;
let outcome = engine.materialize().map_err(exec_err)?;
let files: serde_json::Map<String, serde_json::Value> = loaded
.iter()
.map(|(f, n)| (f.clone(), serde_json::json!(n)))
.collect();
Ok(serde_json::json!({
"rules_loaded": outcome.rules_loaded,
"rules_per_file": files,
"derived": outcome.derived.len(),
"derived_triples": outcome.derived,
}))
}
pub fn handle_law_export() -> Result<serde_json::Value> {
use std::fmt::Write as _;
use crate::graph::GraphEngine as _;
let root = project_root()?;
let (_, engine, _) = build_law_engine(&root)?;
engine.materialize().map_err(exec_err)?;
let lines = engine.canonical_quads().map_err(exec_err)?;
let hash = engine.state_hash().map_err(exec_err)?;
let ntriples: String = lines.iter().fold(String::new(), |mut doc, l| {
let _ = writeln!(doc, "{l} .");
doc
});
Ok(serde_json::json!({
"triples": lines.len(),
"graph_hash": crate::sync::hex32(&hash),
"ntriples": ntriples,
}))
}