#[cfg(test)]
mod tests;
pub mod coherence_gate;
pub use coherence_gate::{CoherenceGate, CoherenceGateConfig};
use crate::codegen::go::GoCodeGenerator;
use crate::graph::types::CachedResult;
use crate::graph::Graph;
use crate::utils::error::Error as GgenError;
use crate::validation::soundness_gates::{
check_boundedness, check_deadlock_freedom, check_liveness, SoundnessViolation,
};
use ggen_graph::{CoherenceChecker, CoherenceReport};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub const SPARQL_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncLanguage {
Go,
Elixir,
Rust,
TypeScript,
Python,
Auto,
}
impl std::fmt::Display for SyncLanguage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Go => write!(f, "go"),
Self::Elixir => write!(f, "elixir"),
Self::Rust => write!(f, "rust"),
Self::TypeScript => write!(f, "typescript"),
Self::Python => write!(f, "python"),
Self::Auto => write!(f, "auto"),
}
}
}
impl std::str::FromStr for SyncLanguage {
type Err = SyncError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"go" | "golang" => Ok(Self::Go),
"elixir" => Ok(Self::Elixir),
"rust" => Ok(Self::Rust),
"typescript" | "ts" => Ok(Self::TypeScript),
"python" | "py" => Ok(Self::Python),
"auto" => Ok(Self::Auto),
other => Err(SyncError::InvalidLanguage(other.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub struct SyncConfig {
pub ontology_path: PathBuf,
pub queries_dir: PathBuf,
pub output_dir: PathBuf,
pub language: SyncLanguage,
pub validate: bool,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct SyncResult {
pub files_generated: Vec<PathBuf>,
pub soundness_violations: Vec<SoundnessViolation>,
pub elapsed_ms: u64,
pub receipt: String,
pub coherence_report: Option<CoherenceReport>,
}
#[derive(Debug, thiserror::Error)]
pub enum SyncError {
#[error("ontology load failed: {0}")]
OntologyLoad(String),
#[error("ontology parse failed: {0}")]
OntologyParse(String),
#[error("queries directory error: {0}")]
QueriesDir(String),
#[error("query file read error ({path}): {message}")]
QueryRead {
path: PathBuf,
message: String,
},
#[error("SPARQL execution error ({query}): {message}")]
SparqlExecution {
query: String,
message: String,
},
#[error("SPARQL timeout after {SPARQL_TIMEOUT_SECS}s for query '{query}'")]
SparqlTimeout {
query: String,
},
#[error("code generation failed: {0}")]
Codegen(String),
#[error("file write error ({path}): {message}")]
FileWrite {
path: PathBuf,
message: String,
},
#[error("cannot create output directory ({path}): {message}")]
OutputDir {
path: PathBuf,
message: String,
},
#[error("unknown language '{0}'; valid values: go, elixir, rust, typescript, python, auto")]
InvalidLanguage(String),
#[error("coherence violation: {detail}")]
CoherenceViolation {
detail: String,
report: CoherenceReport,
},
}
impl From<GgenError> for SyncError {
fn from(e: GgenError) -> Self {
Self::OntologyParse(e.to_string())
}
}
pub fn sync(config: SyncConfig) -> Result<SyncResult, SyncError> {
let start = Instant::now();
let (graph, ontology_bytes) = load_ontology(&config.ontology_path)?;
let bindings = run_sparql_queries(&graph, &config.queries_dir)?;
let generated = generate_code(&config, &bindings)?;
let mut soundness_violations = Vec::new();
if config.validate {
for (_path, source) in &generated {
soundness_violations.extend(check_deadlock_freedom(source));
soundness_violations.extend(check_liveness(source));
soundness_violations.extend(check_boundedness(source));
}
}
let coherence_gate_config = CoherenceGateConfig {
allow_count_discrepancy: false,
check_event_log: !config.dry_run,
expectations: None,
};
let gate = CoherenceGate::new(coherence_gate_config);
let generated_pairs: Vec<(String, String)> = generated
.iter()
.map(|(p, c)| (p.to_string_lossy().to_string(), c.clone()))
.collect();
let coherence_report = gate.validate(&ontology_bytes, &generated_pairs, &[])?;
let files_generated = write_files(&config, &generated)?;
let receipt = compute_receipt(&ontology_bytes, &generated);
if !config.dry_run {
if let Ok(json) = serde_json::to_string_pretty(&coherence_report) {
let receipts_dir = config.output_dir.join(".ggen").join("receipts");
let _ = std::fs::create_dir_all(&receipts_dir);
let _ = std::fs::write(receipts_dir.join("coherence-latest.json"), json);
}
}
let elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
Ok(SyncResult {
files_generated,
soundness_violations,
elapsed_ms,
receipt,
coherence_report: Some(coherence_report),
})
}
fn load_ontology(path: &Path) -> Result<(Graph, Vec<u8>), SyncError> {
let bytes = fs::read(path).map_err(|e| SyncError::OntologyLoad(e.to_string()))?;
let graph = Graph::new().map_err(|e| SyncError::OntologyParse(e.to_string()))?;
graph
.insert_turtle(
std::str::from_utf8(&bytes)
.map_err(|e| SyncError::OntologyParse(format!("UTF-8 error: {e}")))?,
)
.map_err(|e| SyncError::OntologyParse(e.to_string()))?;
Ok((graph, bytes))
}
pub type QueryRow = BTreeMap<String, String>;
fn run_sparql_queries(graph: &Graph, queries_dir: &Path) -> Result<Vec<QueryRow>, SyncError> {
if !queries_dir.exists() {
return Err(SyncError::QueriesDir(format!(
"directory does not exist: {}",
queries_dir.display()
)));
}
let mut query_files: Vec<PathBuf> = fs::read_dir(queries_dir)
.map_err(|e| SyncError::QueriesDir(e.to_string()))?
.filter_map(|entry| entry.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("rq"))
.collect();
query_files.sort();
let timeout = Duration::from_secs(SPARQL_TIMEOUT_SECS);
let mut all_rows: Vec<QueryRow> = Vec::new();
for query_path in &query_files {
let query_name = query_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let sparql = fs::read_to_string(query_path).map_err(|e| SyncError::QueryRead {
path: query_path.clone(),
message: e.to_string(),
})?;
let rows = execute_with_timeout(graph, &sparql, &query_name, timeout)?;
all_rows.extend(rows);
}
Ok(all_rows)
}
fn execute_with_timeout(
graph: &Graph, sparql: &str, query_name: &str, timeout: Duration,
) -> Result<Vec<QueryRow>, SyncError> {
let before = Instant::now();
let cached = graph
.query_cached(sparql)
.map_err(|e| SyncError::SparqlExecution {
query: query_name.to_string(),
message: e.to_string(),
})?;
if before.elapsed() > timeout {
return Err(SyncError::SparqlTimeout {
query: query_name.to_string(),
});
}
match cached {
CachedResult::Solutions(rows) => Ok(rows),
CachedResult::Boolean(_) | CachedResult::Graph(_) => {
Ok(Vec::new())
}
}
}
fn generate_code(
config: &SyncConfig, bindings: &[QueryRow],
) -> Result<Vec<(PathBuf, String)>, SyncError> {
let effective_language = resolve_language(&config.language, &config.ontology_path);
match effective_language {
SyncLanguage::Go => generate_go(bindings),
SyncLanguage::Elixir => generate_elixir(bindings),
SyncLanguage::Rust => generate_rust(bindings),
SyncLanguage::TypeScript => generate_typescript(bindings),
SyncLanguage::Python => generate_python(bindings),
SyncLanguage::Auto => {
Err(SyncError::InvalidLanguage("auto".to_string()))
}
}
}
fn resolve_language(lang: &SyncLanguage, ontology_path: &Path) -> SyncLanguage {
if *lang != SyncLanguage::Auto {
return lang.clone();
}
let stem = ontology_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if stem.contains("elixir") || stem.contains("canopy") || stem.contains("osa") {
SyncLanguage::Elixir
} else if stem.contains("rust") || stem.contains("pm4py") {
SyncLanguage::Rust
} else if stem.contains("typescript") || stem.contains("svelte") {
SyncLanguage::TypeScript
} else {
SyncLanguage::Go
}
}
fn generate_go(bindings: &[QueryRow]) -> Result<Vec<(PathBuf, String)>, SyncError> {
let mut outputs: Vec<(PathBuf, String)> = Vec::new();
let service_groups = group_by_key(bindings, "service");
if service_groups.is_empty() {
let source = GoCodeGenerator::generate_service_struct("GeneratedService", &[])
.map_err(SyncError::Codegen)?;
outputs.push((PathBuf::from("generated_service.go"), source));
} else {
for service_name in service_groups.keys() {
let source = GoCodeGenerator::generate_service_struct(service_name, &[])
.map_err(SyncError::Codegen)?;
let file_name = format!("{}.go", service_name.to_ascii_lowercase().replace(' ', "_"));
outputs.push((PathBuf::from(file_name), source));
}
}
Ok(outputs)
}
fn generate_elixir(bindings: &[QueryRow]) -> Result<Vec<(PathBuf, String)>, SyncError> {
let module_groups = group_by_key(bindings, "service");
let mut outputs: Vec<(PathBuf, String)> = Vec::new();
if module_groups.is_empty() {
let source = elixir_genserver_template("GeneratedService");
outputs.push((PathBuf::from("generated_service.ex"), source));
} else {
for module_name in module_groups.keys() {
let source = elixir_genserver_template(module_name);
let file_name = format!("{}.ex", module_name.to_ascii_lowercase().replace(' ', "_"));
outputs.push((PathBuf::from(file_name), source));
}
}
Ok(outputs)
}
fn generate_rust(bindings: &[QueryRow]) -> Result<Vec<(PathBuf, String)>, SyncError> {
let struct_groups = group_by_key(bindings, "service");
let mut outputs: Vec<(PathBuf, String)> = Vec::new();
if struct_groups.is_empty() {
let source = rust_struct_template("GeneratedService");
outputs.push((PathBuf::from("generated_service.rs"), source));
} else {
for struct_name in struct_groups.keys() {
let source = rust_struct_template(struct_name);
let file_name = format!("{}.rs", struct_name.to_ascii_lowercase().replace(' ', "_"));
outputs.push((PathBuf::from(file_name), source));
}
}
Ok(outputs)
}
fn generate_typescript(bindings: &[QueryRow]) -> Result<Vec<(PathBuf, String)>, SyncError> {
let interface_groups = group_by_key(bindings, "service");
let mut outputs: Vec<(PathBuf, String)> = Vec::new();
if interface_groups.is_empty() {
let source = typescript_interface_template("GeneratedService");
outputs.push((PathBuf::from("generatedService.ts"), source));
} else {
for interface_name in interface_groups.keys() {
let source = typescript_interface_template(interface_name);
let file_name = format!("{}.ts", to_camel_case(interface_name));
outputs.push((PathBuf::from(file_name), source));
}
}
Ok(outputs)
}
fn generate_python(bindings: &[QueryRow]) -> Result<Vec<(PathBuf, String)>, SyncError> {
let class_groups = group_by_key(bindings, "service");
let mut outputs: Vec<(PathBuf, String)> = Vec::new();
if class_groups.is_empty() {
let source = python_dataclass_template("GeneratedService");
outputs.push((PathBuf::from("generated_service.py"), source));
} else {
for class_name in class_groups.keys() {
let source = python_dataclass_template(class_name);
let file_name = format!("{}.py", class_name.to_ascii_lowercase().replace(' ', "_"));
outputs.push((PathBuf::from(file_name), source));
}
}
Ok(outputs)
}
fn elixir_genserver_template(module_name: &str) -> String {
format!(
r#"defmodule {module_name} do
@moduledoc "Generated by ggen sync"
use GenServer
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, :ok, opts)
end
def init(:ok) do
{{:ok, %{{}}}}
end
end
"#,
module_name = module_name
)
}
fn rust_struct_template(struct_name: &str) -> String {
format!(
r"//! Generated by ggen sync
use serde::{{Deserialize, Serialize}};
/// {struct_name} — generated from ontology
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {struct_name} {{
pub id: String,
}}
impl {struct_name} {{
/// Create a new instance
pub fn new(id: impl Into<String>) -> Self {{
Self {{ id: id.into() }}
}}
}}
",
struct_name = struct_name
)
}
fn typescript_interface_template(interface_name: &str) -> String {
format!(
r"// Generated by ggen sync
export interface {interface_name} {{
id: string;
}}
export function create{interface_name}(id: string): {interface_name} {{
return {{ id }};
}}
",
interface_name = interface_name
)
}
fn python_dataclass_template(class_name: &str) -> String {
format!(
r"# Generated by ggen sync
from dataclasses import dataclass
@dataclass
class {class_name}:
id: str
",
class_name = class_name
)
}
fn write_files(
config: &SyncConfig, generated: &[(PathBuf, String)],
) -> Result<Vec<PathBuf>, SyncError> {
if !config.dry_run && !config.output_dir.exists() {
fs::create_dir_all(&config.output_dir).map_err(|e| SyncError::OutputDir {
path: config.output_dir.clone(),
message: e.to_string(),
})?;
}
let mut written = Vec::new();
for (rel_path, source) in generated {
let abs_path = config.output_dir.join(rel_path);
if !config.dry_run {
if let Some(parent) = abs_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| SyncError::OutputDir {
path: parent.to_path_buf(),
message: e.to_string(),
})?;
}
}
fs::write(&abs_path, source).map_err(|e| SyncError::FileWrite {
path: abs_path.clone(),
message: e.to_string(),
})?;
}
written.push(abs_path);
}
Ok(written)
}
fn compute_receipt(ontology_bytes: &[u8], generated: &[(PathBuf, String)]) -> String {
let mut hasher = Sha256::new();
hasher.update(ontology_bytes);
let mut sorted: Vec<(&PathBuf, &str)> =
generated.iter().map(|(p, s)| (p, s.as_str())).collect();
sorted.sort_by_key(|(p, _)| p.as_path());
for (_path, source) in &sorted {
hasher.update(source.as_bytes());
}
let digest = hasher.finalize();
hex::encode(digest)
}
fn group_by_key(rows: &[QueryRow], key: &str) -> BTreeMap<String, Vec<QueryRow>> {
let mut groups: BTreeMap<String, Vec<QueryRow>> = BTreeMap::new();
for row in rows {
if let Some(value) = row.get(key) {
let clean = strip_iri_brackets(value);
groups.entry(clean).or_default().push(row.clone());
}
}
groups
}
fn strip_iri_brackets(iri: &str) -> String {
let inner = iri.trim_start_matches('<').trim_end_matches('>');
if let Some(fragment) = inner.rsplit_once('#').map(|(_, f)| f) {
return fragment.to_string();
}
if let Some(segment) = inner.rsplit('/').next() {
if !segment.is_empty() {
return segment.to_string();
}
}
inner.to_string()
}
fn to_camel_case(s: &str) -> String {
let mut result = String::new();
let mut capitalise_next = false;
for (i, c) in s.chars().enumerate() {
if c == ' ' || c == '_' || c == '-' {
capitalise_next = true;
} else if capitalise_next {
result.extend(c.to_uppercase());
capitalise_next = false;
} else if i == 0 {
result.extend(c.to_lowercase());
} else {
result.push(c);
}
}
result
}