use clap::Args;
use std::collections::BTreeMap;
use std::path::PathBuf;
use tera::Context;
use rgen_utils::error::Result;
use rgen_core::graph::Graph;
use rgen_core::template::Template;
#[derive(Args, Debug)]
pub struct LintArgs {
#[arg(value_name = "TEMPLATE")]
pub template: PathBuf,
#[arg(short = 'v', long = "var", value_parser = parse_key_val::<String, String>)]
pub vars: Vec<(String, String)>,
#[arg(long)]
pub verbose: bool,
#[arg(long)]
pub shacl: bool,
}
fn parse_key_val<K, V>(s: &str) -> std::result::Result<(K, V), String>
where
K: std::str::FromStr,
K::Err: ToString,
V: std::str::FromStr,
V::Err: ToString,
{
let pos = s
.find('=')
.ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?;
let key = s[..pos].parse().map_err(|e: K::Err| e.to_string())?;
let val = s[pos + 1..].parse().map_err(|e: V::Err| e.to_string())?;
Ok((key, val))
}
pub fn run(args: &LintArgs) -> Result<()> {
let mut issues = Vec::new();
let template_content = std::fs::read_to_string(&args.template)?;
let mut template = Template::parse(&template_content)?;
let mut vars = BTreeMap::new();
for (k, v) in &args.vars {
vars.insert(k.clone(), v.clone());
}
let mut ctx = Context::from_serialize(&vars)?;
insert_env(&mut ctx);
let mut tera = tera::Tera::default();
tera.autoescape_on(vec![]);
rgen_core::register::register_all(&mut tera);
template.render_frontmatter(&mut tera, &ctx)?;
validate_frontmatter_schema(&template.front, &mut issues);
validate_sparql_queries(&template.front, &mut issues);
validate_rdf_content(&template.front, &mut issues);
if args.shacl {
validate_shacl(&template.front, &mut issues);
}
if issues.is_empty() {
println!("✓ No linting issues found");
} else {
println!("Found {} linting issue(s):", issues.len());
for (i, issue) in issues.iter().enumerate() {
println!("{}. {}", i + 1, issue);
}
return Err(rgen_utils::error::Error::new("Linting failed"));
}
Ok(())
}
fn validate_frontmatter_schema(
frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>,
) {
if frontmatter.inject {
if frontmatter.to.is_none() {
issues.push("Injection mode requires 'to:' field to specify target file".to_string());
}
let _injection_modes = [
frontmatter.before.is_some(),
frontmatter.after.is_some(),
frontmatter.prepend,
frontmatter.append,
frontmatter.at_line.is_some(),
];
let active_modes: Vec<&str> = [
(frontmatter.before.is_some(), "before"),
(frontmatter.after.is_some(), "after"),
(frontmatter.prepend, "prepend"),
(frontmatter.append, "append"),
(frontmatter.at_line.is_some(), "at_line"),
]
.iter()
.filter_map(|(active, name)| if *active { Some(*name) } else { None })
.collect();
if active_modes.len() > 1 {
issues.push(format!(
"Multiple injection modes specified: {}. Only one should be used",
active_modes.join(", ")
));
}
if active_modes.is_empty() {
issues.push("Injection mode enabled but no injection method specified (before, after, prepend, append, at_line)".to_string());
}
}
if frontmatter.sh_before.is_some() && !frontmatter.inject {
issues.push("sh_before specified but injection mode is not enabled".to_string());
}
if frontmatter.sh_after.is_some() && !frontmatter.inject {
issues.push("sh_after specified but injection mode is not enabled".to_string());
}
if !frontmatter.rdf.is_empty() && !frontmatter.rdf_inline.is_empty() {
}
for (name, query) in &frontmatter.sparql {
if name.trim().is_empty() {
issues.push("SPARQL query name cannot be empty".to_string());
}
if query.trim().is_empty() {
issues.push(format!("SPARQL query '{}' is empty", name));
}
}
}
fn validate_sparql_queries(frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>) {
for (name, query) in &frontmatter.sparql {
if !query.to_uppercase().contains("SELECT")
&& !query.to_uppercase().contains("ASK")
&& !query.to_uppercase().contains("CONSTRUCT")
&& !query.to_uppercase().contains("DESCRIBE")
{
issues.push(format!("SPARQL query '{}' does not appear to be a valid query type (SELECT, ASK, CONSTRUCT, DESCRIBE)", name));
}
if query.contains("{{") && !query.contains("}}") {
issues.push(format!(
"SPARQL query '{}' has unclosed template variable",
name
));
}
if query.contains("}}") && !query.contains("{{") {
issues.push(format!(
"SPARQL query '{}' has template closing without opening",
name
));
}
}
}
fn validate_rdf_content(frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>) {
for (i, rdf_content) in frontmatter.rdf_inline.iter().enumerate() {
if rdf_content.trim().is_empty() {
issues.push(format!("Inline RDF block {} is empty", i + 1));
}
if rdf_content.contains("@prefix") && !rdf_content.contains(" .") {
issues.push(format!(
"Inline RDF block {} has @prefix without proper termination",
i + 1
));
}
}
for (i, rdf_file) in frontmatter.rdf.iter().enumerate() {
if rdf_file.trim().is_empty() {
issues.push(format!("RDF file reference {} is empty", i + 1));
}
}
}
fn validate_shacl(frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>) {
if frontmatter.rdf.is_empty() && frontmatter.rdf_inline.is_empty() {
issues.push("SHACL validation requested but no RDF data is available".to_string());
return;
}
if frontmatter.shape.is_empty() {
issues.push("SHACL validation requested but no shape files specified".to_string());
return;
}
let mut combined_graph = match Graph::new() {
Ok(g) => g,
Err(e) => {
issues.push(format!(
"Failed to initialize graph for SHACL validation: {}",
e
));
return;
}
};
if let Err(e) = load_rdf_data_into_graph(frontmatter, &mut combined_graph) {
issues.push(format!(
"Failed to load RDF data for SHACL validation: {}",
e
));
return;
}
if let Err(e) = load_shacl_shapes_into_graph(frontmatter, &mut combined_graph) {
issues.push(format!("Failed to load SHACL shapes: {}", e));
return;
}
match perform_optimized_shacl_validation(&combined_graph) {
Ok(validation_results) => {
if !validation_results.is_empty() {
for result in validation_results {
issues.push(format!("SHACL validation error: {}", result));
}
}
}
Err(e) => {
issues.push(format!("SHACL validation failed: {}", e));
}
}
}
fn load_rdf_data_into_graph(
frontmatter: &rgen_core::template::Frontmatter, graph: &mut Graph,
) -> Result<()> {
for rdf_content in &frontmatter.rdf_inline {
if !rdf_content.trim().is_empty() {
graph.insert_turtle(rdf_content)?;
}
}
for rdf_file in &frontmatter.rdf {
if !rdf_file.trim().is_empty() {
graph.load_path(rdf_file)?;
}
}
Ok(())
}
fn load_shacl_shapes_into_graph(
frontmatter: &rgen_core::template::Frontmatter, graph: &mut Graph,
) -> Result<()> {
for shape_file in &frontmatter.shape {
if !shape_file.trim().is_empty() {
graph.load_path(shape_file)?;
}
}
Ok(())
}
fn perform_optimized_shacl_validation(combined_graph: &Graph) -> Result<Vec<String>> {
let mut validation_errors = Vec::new();
if combined_graph.is_empty() {
validation_errors
.push("Combined graph is empty - no data or shapes to validate".to_string());
return Ok(validation_errors);
}
let shapes_query = "SELECT ?shape WHERE { ?shape <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/shacl#NodeShape> }";
let shapes_result = combined_graph.query_cached(shapes_query)?;
match shapes_result {
rgen_core::graph::CachedResult::Solutions(solutions) => {
if solutions.is_empty() {
validation_errors.push("No SHACL NodeShapes found in shapes graph".to_string());
} else {
for shape_solution in solutions {
if let Some(shape_iri) = shape_solution.get("shape") {
if let Err(e) = validate_single_shape(combined_graph, shape_iri) {
validation_errors
.push(format!("Shape validation error for {}: {}", shape_iri, e));
}
}
}
}
}
_ => {
validation_errors.push("Failed to query for SHACL shapes".to_string());
}
}
Ok(validation_errors)
}
fn validate_single_shape(graph: &Graph, shape_iri: &str) -> Result<()> {
let properties_query = format!(
"SELECT ?property WHERE {{ <{}> <http://www.w3.org/ns/shacl#property> ?property }}",
shape_iri
);
let properties_result = graph.query_cached(&properties_query)?;
match properties_result {
rgen_core::graph::CachedResult::Solutions(properties) => {
for property_solution in properties {
if let Some(property_iri) = property_solution.get("property") {
validate_property_constraint(graph, property_iri)?;
}
}
}
_ => {
}
}
Ok(())
}
fn validate_property_constraint(graph: &Graph, property_iri: &str) -> Result<()> {
let min_count_query = format!(
"ASK WHERE {{ <{}> <http://www.w3.org/ns/shacl#minCount> ?minCount }}",
property_iri
);
let min_count_result = graph.query_cached(&min_count_query)?;
match min_count_result {
rgen_core::graph::CachedResult::Boolean(true) => {
}
_ => {
}
}
Ok(())
}
fn insert_env(ctx: &mut Context) {
let mut env_map: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in std::env::vars() {
env_map.insert(k, v);
}
ctx.insert("env", &env_map);
if let Ok(cwd) = std::env::current_dir() {
ctx.insert("cwd", &cwd.display().to_string());
}
}