use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
sync::Arc,
time::Instant,
};
use ggen_config::manifest::{
GenerationMode, GenerationRule, GgenManifest, PackRef, PackageToml, QuerySource,
TemplateSource, ValidationSeverity,
};
use tera::Value;
use crate::{
error::{AppError, Result, TemplateFailureCause},
graph::{EngineQueryResults, GraphEngine, TurtleDocument},
sync::{
hash_file_or_missing, hex32, new_graph_engine, read_ontology_file, rel_display,
write_receipt, SyncOptions, SyncReport,
},
template::{
build_tera_with_packs, classify_tera_render_error, solutions_to_values,
tera_error_full_chain, tera_error_location,
},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RuleSourceError {
Unreadable { path: PathBuf, reason: String },
DatalogUnsupported { path: PathBuf },
}
impl std::fmt::Display for RuleSourceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unreadable { path, reason } => write!(
f,
"rule file `{}` unreadable: {reason}. \
Remediation: fix the `[rules]` path in ggen.toml.",
path.display()
),
Self::DatalogUnsupported { path } => write!(
f,
"UNSUPPORTED: Datalog rule file `{}` cannot be used — \
ggen-engine has no Datalog text parser this release \
(graphlaw exposes N3 only). \
Remediation: translate the rules to N3 and declare them \
under `[rules].n3`.",
path.display()
),
}
}
}
impl std::error::Error for RuleSourceError {}
pub(crate) fn resolve_rule_sources(
root: &Path, rel_paths: &[String],
) -> std::result::Result<Vec<(PathBuf, String)>, RuleSourceError> {
let mut resolved = Vec::with_capacity(rel_paths.len());
for rel in rel_paths {
let path = root.join(rel);
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("datalog"))
{
return Err(RuleSourceError::DatalogUnsupported { path });
}
let contents = std::fs::read_to_string(&path).map_err(|e| RuleSourceError::Unreadable {
path: path.clone(),
reason: e.to_string(),
})?;
resolved.push((path, contents));
}
Ok(resolved)
}
pub(crate) fn duration_ms(elapsed: std::time::Duration) -> u64 {
u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
}
#[allow(clippy::too_many_lines)]
pub(crate) fn run(root: &Path, manifest: &GgenManifest, opts: SyncOptions) -> Result<SyncReport> {
let load_start = Instant::now();
let load_span = tracing::info_span!(
"pipeline.load",
"operation.name" = "pipeline.load",
"operation.type" = "pipeline",
"pipeline.stage" = "load",
"pipeline.duration_ms" = tracing::field::Empty,
);
let load_guard = load_span.enter();
let graph: Arc<dyn GraphEngine> = new_graph_engine(opts.engine)?;
let mut closure: BTreeMap<String, String> = BTreeMap::new();
closure.insert(
"actuator".to_string(),
concat!("ggen@", env!("CARGO_PKG_VERSION")).to_string(),
);
let ontology_path = root.join(&manifest.ontology.source);
let (ontology_label, ttl) = read_ontology_file(root, &ontology_path)?;
closure.insert(ontology_label.clone(), hash_file_or_missing(&ontology_path));
let mut ontology_sources = Vec::with_capacity(1 + manifest.ontology.imports.len());
ontology_sources.push((ontology_label, ttl));
for import in &manifest.ontology.imports {
let import_path = root.join(import);
let (import_label, import_ttl) = read_ontology_file(root, &import_path)?;
closure.insert(import_label.clone(), hash_file_or_missing(&import_path));
ontology_sources.push((import_label, import_ttl));
}
let ontology_documents: Vec<TurtleDocument<'_>> = ontology_sources
.iter()
.map(|(label, content)| TurtleDocument::new(label, content))
.collect();
let ontology_receipt = graph.insert_turtle_documents(&ontology_documents)?;
tracing::debug!(
ontology.documents = ontology_receipt.documents,
ontology.parsed_quads = ontology_receipt.parsed_quads,
ontology.inserted_quads = ontology_receipt.inserted_quads,
"ontology batch admitted"
);
drop(load_guard);
load_span.record("pipeline.duration_ms", duration_ms(load_start.elapsed()));
let extract_start = Instant::now();
let extract_span = tracing::info_span!(
"pipeline.extract",
"operation.name" = "pipeline.extract",
"operation.type" = "pipeline",
"pipeline.stage" = "extract",
"pipeline.duration_ms" = tracing::field::Empty,
);
let extract_guard = extract_span.enter();
{
let mut ordered: Vec<&ggen_config::manifest::InferenceRule> =
manifest.inference.rules.iter().collect();
ordered.sort_by_key(|r| r.order);
for rule in ordered {
if let Some(ask) = rule.when.as_deref() {
match graph.query(ask)? {
EngineQueryResults::Boolean(true) => {}
EngineQueryResults::Boolean(false) => continue,
_ => {
return Err(AppError::fm_gen(
13,
format!(
"inference rule `{}`: `when:` must be an ASK query. \
Remediation: use ASK {{ … }}.",
rule.name
),
));
}
}
}
let EngineQueryResults::Graph(derived) = graph.query(&rule.construct)? else {
return Err(AppError::fm_gen(
14,
format!(
"inference rule `{}`: `construct:` must be a CONSTRUCT query. \
Remediation: use CONSTRUCT {{ … }} WHERE {{ … }}.",
rule.name
),
));
};
if !derived.is_empty() {
use std::fmt::Write as _;
let doc: String = derived.iter().fold(String::new(), |mut doc, t| {
let _ = writeln!(doc, "{} .", t.ntriples);
doc
});
graph.insert_turtle(&doc)?;
}
}
}
drop(extract_guard);
extract_span.record("pipeline.duration_ms", duration_ms(extract_start.elapsed()));
let validate_start = Instant::now();
let validate_span = tracing::info_span!(
"pipeline.validate",
"operation.name" = "pipeline.validate",
"operation.type" = "pipeline",
"pipeline.stage" = "validate",
"pipeline.duration_ms" = tracing::field::Empty,
);
let validate_guard = validate_span.enter();
if !manifest.law.rules.is_empty() {
for rel in &manifest.law.rules {
let rule_path = root.join(rel);
let src = std::fs::read_to_string(&rule_path).map_err(|e| {
AppError::fm_law(
15,
format!(
"rule file `{}` unreadable: {e}. Remediation: fix [law].rules.",
rule_path.display()
),
)
})?;
closure.insert(
rel_display(root, &rule_path),
hash_file_or_missing(&rule_path),
);
graph.load_rules(&src)?;
}
graph.materialize()?;
let denials = graph.check_denials()?;
if !denials.is_empty() {
return Err(AppError::fm_law(
16,
format!(
"{} denial rule(s) violated after materialization: {}. \
Remediation: fix the facts or the denial rules in [law].rules.",
denials.len(),
denials.join("; ")
),
));
}
}
if !manifest.validation.shacl.is_empty() {
return Err(AppError::fm_law(
17,
format!(
"[validation].shacl ({} SHACL shapes file(s) declared) is no longer \
supported at sync time; SHACL shape gates were replaced by \
engine-independent SPARQL gate queries. Remediation: migrate to \
[validation].gates = [\"…/*.rq\"] — each file holds one ASK (true = \
violation) or SELECT (any row = violation) query, optionally \
preceded by `# MESSAGE: <text>` comment lines.",
manifest.validation.shacl.len()
),
));
}
for rel in &manifest.validation.gates {
let gate_path = root.join(rel);
let src = std::fs::read_to_string(&gate_path).map_err(|e| {
AppError::fm_law(
17,
format!(
"SPARQL gate file `{}` unreadable: {e}. Remediation: fix [validation].gates.",
gate_path.display()
),
)
})?;
closure.insert(
rel_display(root, &gate_path),
hash_file_or_missing(&gate_path),
);
let gate = crate::sync::parse_gate_source(&src);
match crate::sync::evaluate_gate(graph.as_ref(), &gate.query)? {
crate::sync::GateOutcome::Pass => {}
crate::sync::GateOutcome::NotAGate => {
return Err(AppError::fm_law(
17,
format!(
"SPARQL gate `{}` is not a gate query: it must be an ASK \
(true = violation) or a SELECT (any row = violation), not \
a CONSTRUCT/DESCRIBE. Remediation: fix [validation].gates.",
rel.display()
),
));
}
crate::sync::GateOutcome::Violation(detail) => {
return Err(AppError::fm_law(
18,
format!(
"SPARQL gate `{}` refused the sync: {}{detail}. \
Remediation: fix the offending facts or the gate query.",
rel.display(),
gate.message_prefix(),
),
));
}
}
}
for rule in &manifest.validation.rules {
match graph.query(&rule.ask)? {
EngineQueryResults::Boolean(true) => {}
EngineQueryResults::Boolean(false) => match rule.severity {
ValidationSeverity::Error => {
return Err(AppError::fm_law(
20,
format!(
"validation rule `{}` violated: {}. \
Remediation: fix the underlying facts, or set \
`severity = \"Warning\"` on this [[validation.rules]] entry \
if it should log instead of refusing the sync.",
rule.name, rule.description
),
));
}
ValidationSeverity::Warning => {
tracing::warn!(
validation.rule = %rule.name,
validation.description = %rule.description,
"validation rule violated (severity=Warning): sync continues"
);
}
},
_ => {
return Err(AppError::fm_law(
19,
format!(
"validation rule `{}`: `ask` must be an ASK query. \
Remediation: use ASK {{ … }}.",
rule.name
),
));
}
}
}
drop(validate_guard);
validate_span.record(
"pipeline.duration_ms",
duration_ms(validate_start.elapsed()),
);
let graph_hash_hex = hex32(&graph.state_hash()?);
let generate_start = Instant::now();
let generate_span = tracing::info_span!(
"pipeline.generate",
"operation.name" = "pipeline.generate",
"operation.type" = "pipeline",
"pipeline.stage" = "generate",
"pipeline.duration_ms" = tracing::field::Empty,
);
let generate_guard = generate_span.enter();
let mut resolved_packs: Vec<crate::pack::Pack> = Vec::new();
for pack_ref in &manifest.packs {
if let Some(path) = &pack_ref.path {
let pack_root = root.join(path);
if pack_root.is_dir() {
resolved_packs.push(crate::pack::Pack {
name: pack_ref.name.clone(),
version: String::new(),
description: String::new(),
dependencies: BTreeMap::new(),
semantic_types: BTreeSet::new(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
root: pack_root,
ontology_path: PathBuf::new(),
extra_ontology_paths: Vec::new(),
template_paths: Vec::new(),
lock: false,
});
}
}
}
let mut tera = build_tera_with_packs(Arc::clone(&graph), &resolved_packs)?;
let mut skipped: Vec<(PathBuf, String)> = Vec::new();
let mut decisions: BTreeMap<String, String> = BTreeMap::new();
let mut pending: Vec<PendingGenWrite> = Vec::new();
let root_display = root.display().to_string();
for rule in &manifest.generation.rules {
if let Some(ask) = rule.when.as_deref() {
match graph.query(ask)? {
EngineQueryResults::Boolean(true) => {}
EngineQueryResults::Boolean(false) => {
let reason = format!("when guard false (rule `{}`)", rule.name);
decisions.insert(rule.output_file.clone(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(&rule.output_file), reason));
continue;
}
_ => {
return Err(AppError::fm_gen(
1,
format!(
"rule `{}`: `when:` must be an ASK query. \
Remediation: use ASK {{ … }}.",
rule.name
),
));
}
}
}
let query_text =
resolve_query_source(root, &manifest.packs, rule, &rule.query, &mut closure)?;
let rows = match graph.query(&query_text)? {
EngineQueryResults::Solutions(rows) => rows,
EngineQueryResults::Boolean(_) => {
return Err(AppError::fm_gen(
2,
format!(
"rule `{}`: query must be a SELECT (got an ASK). \
Remediation: use `when:` for ASK guards, `query:` for SELECT.",
rule.name
),
));
}
EngineQueryResults::Graph(_) => {
return Err(AppError::fm_gen(
2,
format!(
"rule `{}`: query must be a SELECT (got a CONSTRUCT/DESCRIBE). \
Remediation: use a SELECT query.",
rule.name
),
));
}
};
if rows.is_empty() && rule.skip_empty {
let reason = "skip_empty: query returned no rows".to_string();
decisions.insert(rule.output_file.clone(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(&rule.output_file), reason));
continue;
}
let row_values = solutions_to_values(rows);
let template_text =
resolve_template_source(root, &manifest.packs, rule, &rule.template, &mut closure)?;
let template_descriptor = template_source_descriptor(&rule.template);
if detect_file_tree_meta_spec(&template_text) {
return Err(AppError::fm_gen_render_failure(
TemplateFailureCause::TemplateSchemaIncompatible,
&root_display,
&template_descriptor,
&rule.name,
"template is a YAML file-tree meta-spec (`structure:` + `foreach:` \
directives), not Tera content — ggen-engine's declarative-rules path \
has no file-tree/foreach interpreter yet. Remediation: rewrite the rule \
as one flat Tera template per output file, or track the interpreter as \
a follow-up (see specs/014-ggen-core-replacement/tasks.md).",
None,
));
}
let tpl_name = format!("generation_rule::{}", rule.name);
tera.add_raw_template(&tpl_name, &template_text)
.map_err(|e| {
AppError::fm_gen_render_failure(
TemplateFailureCause::TemplateParseFailed,
&root_display,
&template_descriptor,
&rule.name,
format!("template rejected by Tera: {}", tera_error_full_chain(&e)),
tera_error_location(&e).as_deref(),
)
})?;
let per_row = rule.output_file.contains("{{");
if per_row {
for row in &row_values {
let mut ctx = tera::Context::new();
ctx.insert("results", &row_values);
ctx.insert("sparql_results", &row_values);
ctx.insert("row", row);
if let Value::Object(map) = row {
for (k, v) in map {
ctx.insert(k, v);
}
}
let to = render_output_file(
&mut tera,
&rule.output_file,
&ctx,
&rule.name,
&root_display,
&template_descriptor,
)?;
let to = join_output_dir(&manifest.generation.output_dir, &to);
let body = render_template(
&mut tera,
&tpl_name,
&ctx,
&rule.name,
&root_display,
&template_descriptor,
)?;
validate_rendered_body(&rule.name, &to, &body)?;
enforce_validation_policy(manifest, &rule.name, &to, &body)?;
pending.push(PendingGenWrite {
to,
body,
mode: rule.mode.clone(),
});
}
} else {
let mut ctx = tera::Context::new();
ctx.insert("results", &row_values);
ctx.insert("sparql_results", &row_values);
if let Some(first) = row_values.first() {
ctx.insert("row", first);
if let Value::Object(map) = first {
for (k, v) in map {
ctx.insert(k, v);
}
}
}
let body = render_template(
&mut tera,
&tpl_name,
&ctx,
&rule.name,
&root_display,
&template_descriptor,
)?;
let to = join_output_dir(&manifest.generation.output_dir, &rule.output_file);
validate_rendered_body(&rule.name, &to, &body)?;
enforce_validation_policy(manifest, &rule.name, &to, &body)?;
pending.push(PendingGenWrite {
to,
body,
mode: rule.mode.clone(),
});
}
}
{
let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
for pw in &pending {
*seen.entry(pw.to.as_str()).or_default() += 1;
}
if let Some((to, n)) = seen.into_iter().find(|(_, n)| *n > 1) {
return Err(AppError::fm_gen(
4,
format!(
"{n} rendered generation rules target the same output `{to}`. \
Remediation: make `output_file` unique per row/rule."
),
));
}
}
drop(generate_guard);
generate_span.record(
"pipeline.duration_ms",
duration_ms(generate_start.elapsed()),
);
let emit_start = Instant::now();
let emit_span = tracing::info_span!(
"pipeline.emit",
"operation.name" = "pipeline.emit",
"operation.type" = "pipeline",
"pipeline.stage" = "emit",
"pipeline.duration_ms" = tracing::field::Empty,
"pipeline.files_generated" = tracing::field::Empty,
);
let emit_guard = emit_span.enter();
let mut written: Vec<PathBuf> = Vec::new();
for pw in &pending {
match decide_and_maybe_apply(root, &pw.to, &pw.body, &pw.mode, opts.dry_run)? {
GenWriteOutcome::Written => {
decisions.insert(pw.to.clone(), "written".to_string());
written.push(PathBuf::from(&pw.to));
}
GenWriteOutcome::PlannedWrite => {
decisions.insert(pw.to.clone(), "planned: write (dry-run)".to_string());
written.push(PathBuf::from(&pw.to));
}
GenWriteOutcome::Skipped(reason) => {
decisions.insert(pw.to.clone(), format!("skipped: {reason}"));
skipped.push((PathBuf::from(&pw.to), reason));
}
}
}
drop(emit_guard);
emit_span.record("pipeline.duration_ms", duration_ms(emit_start.elapsed()));
emit_span.record("pipeline.files_generated", written.len() as u64);
let report = SyncReport {
written,
skipped,
graph_hash_hex,
decisions,
packs: BTreeMap::new(),
closure,
};
if !opts.dry_run {
write_receipt(root, &report, graph.as_ref(), opts.receipt_origin)?;
}
Ok(report)
}
struct PendingGenWrite {
to: String,
body: String,
mode: GenerationMode,
}
fn join_output_dir(output_dir: &Path, rel_to: &str) -> String {
if output_dir.as_os_str().is_empty() || output_dir == Path::new(".") {
return rel_to.to_string();
}
output_dir.join(rel_to).to_string_lossy().into_owned()
}
fn resolve_pack_root(root: &Path, packs: &[PackRef], pack_name: &str) -> Result<PathBuf> {
let pack_ref = packs.iter().find(|p| p.name == pack_name).ok_or_else(|| {
AppError::fm_gen(
6,
format!(
"pack `{pack_name}` is not declared in [[packs]]. \
Remediation: add a `[[packs]]` entry with this `name`."
),
)
})?;
let pack_path = pack_ref.path.as_ref().ok_or_else(|| {
AppError::fm_gen(
6,
format!(
"pack `{pack_name}` has registry `{}` with no local `path` — only local packs \
are resolvable for QuerySource::Pack/TemplateSource::Pack. \
Remediation: vendor the pack locally (a `[[packs]]` entry with `path = ...`), \
or use QuerySource::File/TemplateSource::File pointing at an already-fetched copy.",
pack_ref.registry
),
)
})?;
Ok(root.join(pack_path))
}
fn resolve_pack_file(rule_name: &str, pack_root: &Path, output: &str, file: &Path) -> PathBuf {
let package = PackageToml::load(pack_root);
let output_dir = package.resolve_output_key(output);
if output_dir == output {
tracing::warn!(
rule = rule_name,
pack_root = %pack_root.display(),
output,
"no `[pack.outputs]` entry for `{output}` in this pack's package.toml (or none \
found) — falling back to treating `{output}` as a literal subdirectory name; \
verify that's actually correct, not a coincidental directory-name match"
);
}
pack_root.join(output_dir).join(file)
}
fn resolve_query_source(
root: &Path, packs: &[PackRef], rule: &GenerationRule, source: &QuerySource,
closure: &mut BTreeMap<String, String>,
) -> Result<String> {
match source {
QuerySource::File { file } => {
let path = root.join(file);
let text = std::fs::read_to_string(&path).map_err(|e| {
AppError::fm_gen(
5,
format!(
"rule `{}`: query file `{}` unreadable: {e}",
rule.name,
path.display()
),
)
})?;
closure.insert(rel_display(root, &path), hash_file_or_missing(&path));
Ok(text)
}
QuerySource::Inline { inline } => Ok(inline.clone()),
QuerySource::Pack { pack, output, file } => {
let pack_root = resolve_pack_root(root, packs, pack)?;
let path = resolve_pack_file(&rule.name, &pack_root, output, file);
let text = std::fs::read_to_string(&path).map_err(|e| {
AppError::fm_gen(
5,
format!(
"rule `{}`: query file `{}` (pack `{pack}`, output `{output}`) unreadable: {e}",
rule.name,
path.display()
),
)
})?;
closure.insert(rel_display(root, &path), hash_file_or_missing(&path));
Ok(text)
}
}
}
fn resolve_template_source(
root: &Path, packs: &[PackRef], rule: &GenerationRule, source: &TemplateSource,
closure: &mut BTreeMap<String, String>,
) -> Result<String> {
match source {
TemplateSource::File { file } => {
let path = root.join(file);
let text = std::fs::read_to_string(&path).map_err(|e| {
AppError::fm_gen(
5,
format!(
"rule `{}`: template file `{}` unreadable: {e}",
rule.name,
path.display()
),
)
})?;
closure.insert(rel_display(root, &path), hash_file_or_missing(&path));
Ok(text)
}
TemplateSource::Inline { inline } => Ok(inline.clone()),
TemplateSource::Pack { pack, output, file } => {
let pack_root = resolve_pack_root(root, packs, pack)?;
let path = resolve_pack_file(&rule.name, &pack_root, output, file);
let text = std::fs::read_to_string(&path).map_err(|e| {
AppError::fm_gen(
5,
format!(
"rule `{}`: template file `{}` (pack `{pack}`, output `{output}`) unreadable: {e}",
rule.name,
path.display()
),
)
})?;
closure.insert(rel_display(root, &path), hash_file_or_missing(&path));
Ok(text)
}
TemplateSource::Git { git, .. } => Err(AppError::fm_gen(
7,
format!(
"rule `{}`: TemplateSource::Git (`{git}`) is not implemented yet. \
Remediation: vendor the template locally and use TemplateSource::File; \
see specs/014-ggen-core-replacement/tasks.md for the tracked follow-up \
(a future implementation should reuse crate::pack's existing \
git-clone-and-cache convention).",
rule.name
),
)),
TemplateSource::Package { package, .. } => Err(AppError::fm_gen(
7,
format!(
"rule `{}`: TemplateSource::Package (`{package}`) is not implemented yet. \
Remediation: vendor the template locally and use TemplateSource::File; \
see specs/014-ggen-core-replacement/tasks.md for the tracked follow-up.",
rule.name
),
)),
}
}
fn template_source_descriptor(source: &TemplateSource) -> String {
match source {
TemplateSource::File { file } => file.display().to_string(),
TemplateSource::Inline { .. } => "<inline>".to_string(),
TemplateSource::Pack { pack, output, file } => {
format!("pack:{pack}/{output}/{}", file.display())
}
TemplateSource::Git { git, path, .. } => format!("git:{git}/{}", path.display()),
TemplateSource::Package { package, .. } => format!("package:{package}"),
}
}
fn detect_file_tree_meta_spec(template_text: &str) -> bool {
let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str(template_text) else {
return false;
};
let structure_key = serde_yaml::Value::String("structure".to_string());
let Some(serde_yaml::Value::Sequence(structure)) = map.get(&structure_key) else {
return false;
};
let foreach_key = serde_yaml::Value::String("foreach".to_string());
structure
.iter()
.any(|entry| matches!(entry, serde_yaml::Value::Mapping(m) if m.contains_key(&foreach_key)))
}
fn render_output_file(
tera: &mut tera::Tera, output_file: &str, ctx: &tera::Context, rule_name: &str, example: &str,
template: &str,
) -> Result<String> {
tera.render_str(output_file, ctx).map_err(|e| {
AppError::fm_gen_render_failure(
TemplateFailureCause::TemplateOutputPathInvalid,
example,
template,
rule_name,
format!("output_file render failed: {}", tera_error_full_chain(&e)),
tera_error_location(&e).as_deref(),
)
})
}
fn render_template(
tera: &mut tera::Tera, tpl_name: &str, ctx: &tera::Context, rule_name: &str, example: &str,
template: &str,
) -> Result<String> {
tera.render(tpl_name, ctx).map_err(|e| {
let cause = classify_tera_render_error(&e, tpl_name);
AppError::fm_gen_render_failure(
cause,
example,
template,
rule_name,
format!("template render failed: {}", tera_error_full_chain(&e)),
tera_error_location(&e).as_deref(),
)
})
}
fn validate_rendered_body(rule_name: &str, to: &str, body: &str) -> Result<()> {
if body.is_empty() {
return Err(AppError::fm_gen(
11,
format!(
"rule `{rule_name}`: rendered output for `{to}` is empty. \
Remediation: check that the query returned rows (or that a static \
template body isn't blank) and that `skip_empty` is set if an empty \
result is expected and should be skipped instead of refused."
),
));
}
if body.len() > crate::write::MAX_OUTPUT_BYTES {
return Err(AppError::fm_gen(
12,
format!(
"rule `{rule_name}`: rendered output for `{to}` is {} bytes, over the \
{}-byte cap. Remediation: check the template for an unbounded loop over \
query results, or split it into multiple templates/output files.",
body.len(),
crate::write::MAX_OUTPUT_BYTES
),
));
}
Ok(())
}
fn enforce_validation_policy(
manifest: &GgenManifest, rule_name: &str, to: &str, body: &str,
) -> Result<()> {
if manifest.validation.no_unsafe && contains_unsafe_keyword(body) {
return Err(AppError::fm_gen(
15,
format!(
"rule `{rule_name}`: rendered output for `{to}` contains the `unsafe` \
keyword, refused because `[validation].no_unsafe = true`. \
Remediation: remove the unsafe block from the source template, or set \
`no_unsafe = false` in `[validation]` if unsafe code is intentional for \
this project."
),
));
}
if manifest.validation.validate_syntax
&& Path::new(to)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
{
return Err(AppError::fm_gen(
16,
format!(
"rule `{rule_name}`: `[validation].validate_syntax = true` requests Rust \
syntax validation for `{to}`, but ggen-engine has no Rust-parser \
dependency wired in yet to honestly perform that check. \
Remediation: set `validate_syntax = false` in `[validation]` until real \
syntax validation ships, or validate generated output with an external \
`cargo check`/`rustc` step in your own pipeline."
),
));
}
Ok(())
}
#[must_use]
fn contains_unsafe_keyword(body: &str) -> bool {
const NEEDLE: &[u8] = b"unsafe";
let bytes = body.as_bytes();
if bytes.len() < NEEDLE.len() {
return false;
}
for start in 0..=(bytes.len() - NEEDLE.len()) {
if &bytes[start..start + NEEDLE.len()] != NEEDLE {
continue;
}
let before_is_ident = start > 0 && is_ident_byte(bytes[start - 1]);
let after_idx = start + NEEDLE.len();
let after_is_ident = after_idx < bytes.len() && is_ident_byte(bytes[after_idx]);
if !before_is_ident && !after_is_ident {
return true;
}
}
false
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
enum GenWriteOutcome {
Written,
PlannedWrite,
Skipped(String),
}
fn decide_and_maybe_apply(
root: &Path, rel_to: &str, body: &str, mode: &GenerationMode, dry_run: bool,
) -> Result<GenWriteOutcome> {
let target = crate::write::resolve_target(root, rel_to)?;
let existing = match std::fs::read_to_string(&target) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(AppError::fm_gen(
9,
format!(
"target `{}` exists but is unreadable as UTF-8: {e}. \
Remediation: remove or fix the target file.",
target.display()
),
));
}
};
let (final_body, plan) = match mode {
GenerationMode::Create => {
if existing.is_some() {
return Ok(GenWriteOutcome::Skipped(
"mode=create: target already exists".to_string(),
));
}
(body.to_string(), GenWriteOutcome::Written)
}
GenerationMode::Overwrite => {
if existing.as_deref() == Some(body) {
return Ok(GenWriteOutcome::Skipped(
"unchanged: content identical".to_string(),
));
}
(body.to_string(), GenWriteOutcome::Written)
}
GenerationMode::Merge => {
let merged = merge::merge_sections(body, existing.as_deref().unwrap_or(""))?;
if existing.as_deref() == Some(merged.as_str()) {
return Ok(GenWriteOutcome::Skipped(
"unchanged: merged content identical".to_string(),
));
}
(merged, GenWriteOutcome::Written)
}
};
if dry_run {
return Ok(GenWriteOutcome::PlannedWrite);
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, &final_body)?;
Ok(plan)
}
mod merge {
use crate::error::{AppError, Result};
struct MergeMarkers {
generated_start: usize,
manual_start: usize,
manual_end: usize,
}
fn parse_merge_markers(content: &str) -> Option<MergeMarkers> {
let lines: Vec<&str> = content.lines().collect();
let mut generated_start = None;
let mut manual_start = None;
let mut manual_end = None;
for (idx, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("<<<<<<< GENERATED") {
generated_start = Some(idx);
} else if trimmed == "=======" {
manual_start = Some(idx);
} else if trimmed.starts_with(">>>>>>> MANUAL") {
manual_end = Some(idx);
}
}
match (generated_start, manual_start, manual_end) {
(Some(gs), Some(ms), Some(me)) => Some(MergeMarkers {
generated_start: gs,
manual_start: ms,
manual_end: me,
}),
_ => None,
}
}
pub(super) fn merge_sections(generated_code: &str, existing_content: &str) -> Result<String> {
let Some(markers) = parse_merge_markers(existing_content) else {
return Ok(format!(
"<<<<<<< GENERATED\n{generated_code}\n=======\n// Add your manual code here\n>>>>>>> MANUAL\n"
));
};
let lines: Vec<&str> = existing_content.lines().collect();
if markers.manual_start <= markers.generated_start {
return Err(AppError::fm_gen(
10,
format!(
"invalid merge marker order: GENERATED marker at line {}, ======= marker \
at line {}. Remediation: markers must appear as <<<<<<< GENERATED, then \
=======, then >>>>>>> MANUAL, in that order.",
markers.generated_start, markers.manual_start
),
));
}
if markers.manual_end <= markers.manual_start {
return Err(AppError::fm_gen(
10,
format!(
"invalid merge marker order: ======= marker at line {}, >>>>>>> MANUAL \
marker at line {}. Remediation: the >>>>>>> MANUAL marker must come after \
the ======= separator.",
markers.manual_start, markers.manual_end
),
));
}
let manual_section: String =
lines[(markers.manual_start + 1)..markers.manual_end].join("\n");
let mut merged = String::new();
for line in &lines[..markers.generated_start] {
merged.push_str(line);
merged.push('\n');
}
merged.push_str("<<<<<<< GENERATED\n");
merged.push_str(generated_code);
merged.push_str("\n=======\n");
merged.push_str(&manual_section);
merged.push_str("\n>>>>>>> MANUAL\n");
for line in &lines[(markers.manual_end + 1)..] {
merged.push_str(line);
merged.push('\n');
}
Ok(merged)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn first_time_wraps_in_fresh_markers() {
let result = merge_sections("fn new_fn() {}", "").expect("merge");
assert!(result.contains("<<<<<<< GENERATED"));
assert!(result.contains("fn new_fn() {}"));
assert!(result.contains("======="));
assert!(result.contains(">>>>>>> MANUAL"));
}
#[test]
fn preserves_manual_section_and_replaces_generated() {
let existing = "<<<<<<< GENERATED\nfn old_generated() {}\n=======\nfn manual_code() {}\n>>>>>>> MANUAL\n";
let result = merge_sections("fn new_generated() {}", existing).expect("merge");
assert!(result.contains("fn new_generated() {}"));
assert!(result.contains("fn manual_code() {}"));
assert!(!result.contains("fn old_generated() {}"));
}
#[test]
fn out_of_order_markers_is_err() {
let existing = "=======\n<<<<<<< GENERATED\n>>>>>>> MANUAL\n";
let err = merge_sections("x", existing).expect_err("must refuse");
assert!(err.to_string().contains("FM-GEN-010"), "{err}");
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::match_wildcard_for_single_variants
)] mod rule_kinds_tests {
use super::resolve_rule_sources;
use std::fs;
#[test]
fn n3_file_resolves_to_its_contents() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(
dir.path().join("r.n3"),
"{ ?x a :Cat } => { ?x a :Animal } .",
)
.unwrap();
let out = resolve_rule_sources(dir.path(), &["r.n3".to_string()]).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, dir.path().join("r.n3"));
assert_eq!(out[0].1, "{ ?x a :Cat } => { ?x a :Animal } .");
}
#[test]
fn missing_file_is_typed_unreadable_refusal_naming_the_file() {
let dir = tempfile::TempDir::new().unwrap();
let err = resolve_rule_sources(dir.path(), &["nope.n3".to_string()]).unwrap_err();
match &err {
super::RuleSourceError::Unreadable { path, .. } => {
assert_eq!(path, &dir.path().join("nope.n3"));
}
other => panic!("expected Unreadable, got {other:?}"),
}
assert!(err.to_string().contains("nope.n3"));
}
#[test]
fn datalog_file_is_typed_unsupported_refusal() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(dir.path().join("r.datalog"), "animal(X) :- cat(X).").unwrap();
let err = resolve_rule_sources(dir.path(), &["r.datalog".to_string()]).unwrap_err();
assert_eq!(
err,
super::RuleSourceError::DatalogUnsupported {
path: dir.path().join("r.datalog"),
}
);
assert!(err.to_string().contains("UNSUPPORTED"));
assert!(err.to_string().contains("no Datalog text parser"));
}
#[test]
fn empty_path_list_is_noop_ok() {
let dir = tempfile::TempDir::new().unwrap();
let out = resolve_rule_sources(dir.path(), &[]).unwrap();
assert!(out.is_empty());
}
}