use clap::{Parser, Subcommand};
use log::{debug, info};
use rayon::prelude::*;
use shacl_rust::{
core::{shape::Shape, ShapesInfo},
err::{path_to_str, ShaclError},
parser, rdf, validate,
validation::dataset::ValidationDataset,
};
use std::fmt::{Display, Formatter};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(name = "shacl-validator")]
#[command(author, version, about, long_about = None)]
struct Cli {
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Parse {
#[arg(value_name = "SHAPES_FILE")]
shapes_file: PathBuf,
#[arg(short, long)]
format: Option<String>,
#[arg(short, long, default_value = "pretty")]
output: String,
},
Validate {
#[arg(value_name = "SHAPES_FILE")]
shapes_file: PathBuf,
#[arg(value_name = "DATA_FILE", required = true)]
data_files: Vec<PathBuf>,
#[arg(short = 'd', long)]
data_format: Option<String>,
#[arg(short = 's', long)]
shapes_format: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, default_value = "text")]
output_format: String,
#[arg(long, visible_alias = "quite")]
quiet: bool,
#[arg(long)]
experimental_index: bool,
},
Info {
#[arg(value_name = "SHAPES_FILE")]
shapes_file: PathBuf,
#[arg(short, long)]
format: Option<String>,
#[arg(short, long)]
detailed: bool,
},
}
fn main() -> Result<(), ShaclError> {
let cli = Cli::parse();
let quiet = matches!(cli.command, Commands::Validate { quiet: true, .. });
let log_level = if quiet {
"error"
} else {
match cli.verbose {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
}
};
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level)).init();
debug!("Starting SHACL validator");
match cli.command {
Commands::Parse {
shapes_file,
format,
output,
} => {
info!("Parsing shapes from: {}", shapes_file.display());
parse_shapes_command(shapes_file, format, &output)
}
Commands::Validate {
shapes_file,
data_files,
data_format,
shapes_format,
output,
output_format,
quiet: _,
experimental_index,
} => {
info!("Validating {} data file(s)", data_files.len());
info!("Using shapes: {}", shapes_file.display());
validate_command(
shapes_file,
data_files,
data_format,
shapes_format,
output,
&output_format,
experimental_index,
)
}
Commands::Info {
shapes_file,
format,
detailed,
} => {
info!("Showing info for shapes: {}", shapes_file.display());
info_command(shapes_file, format, detailed)
}
}
}
fn parse_shapes_command(
shapes_file: PathBuf,
format: Option<String>,
output: &str,
) -> Result<(), ShaclError> {
debug!(
"Reading shapes graph from {} with format {}",
shapes_file.display(),
format.as_deref().unwrap_or("auto")
);
let graph = read_graph_from_file(&shapes_file, format.as_deref())?;
info!("Graph loaded with {} triples", graph.len());
let shapes = parser::parse_shapes(&graph)?;
info!("Parsed {} shapes", shapes.len());
match output {
"pretty" => println!("{}", ShapesPretty(&shapes)),
"json" => print_shapes_json(&shapes)?,
"compact" => println!("{}", ShapesCompact(&shapes)),
_ => {
return Err(ShaclError::Parse(format!(
"Unknown output format: {}. Use 'pretty', 'json', or 'compact'",
output
)))
}
}
Ok(())
}
struct ShapesPretty<'a>(&'a [Shape<'a>]);
impl Display for ShapesPretty<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
writeln!(f, "\n{}", "=".repeat(80))?;
writeln!(f, "Parsed {} SHACL Shape(s)", self.0.len())?;
writeln!(f, "{}\n", "=".repeat(80))?;
for (idx, shape) in self.0.iter().enumerate() {
writeln!(f, "Shape #{}:", idx + 1)?;
writeln!(f, "{}", shape)?;
writeln!(f)?;
}
Ok(())
}
}
struct ShapesCompact<'a>(&'a [Shape<'a>]);
impl Display for ShapesCompact<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Parsed {} shape(s):", self.0.len())?;
for (idx, shape) in self.0.iter().enumerate() {
writeln!(
f,
" {}. {} - {} target(s), {} constraint(s)",
idx + 1,
shape.node,
shape.targets.len(),
shape.constraints.len()
)?;
}
Ok(())
}
}
fn print_shapes_json(shapes: &[Shape<'_>]) -> Result<(), ShaclError> {
use serde_json::json;
let shapes_json: Vec<_> = shapes
.iter()
.map(|shape| {
json!({
"node": shape.node.to_string(),
"name": shape.name,
"targets": shape.targets.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
"deactivated": shape.deactivated,
"severity": shape.severity.to_string(),
"messages": shape.message.iter().collect::<Vec<_>>(),
"constraints": shape.constraints.iter().map(|c| c.to_string()).collect::<Vec<_>>(),
"closed": shape.closed.as_ref().map(|c| c.to_string()),
})
})
.collect();
let output = json!({
"shapes": shapes_json,
"count": shapes.len(),
});
println!(
"{}",
serde_json::to_string_pretty(&output)
.map_err(|e| { ShaclError::Parse(format!("Failed to serialize to JSON: {}", e)) })?
);
Ok(())
}
fn info_command(
shapes_file: PathBuf,
format: Option<String>,
detailed: bool,
) -> Result<(), ShaclError> {
debug!(
"Reading shapes graph from {} with format {}",
shapes_file.display(),
format.as_deref().unwrap_or("auto")
);
let graph = read_graph_from_file(&shapes_file, format.as_deref())?;
info!("Graph loaded with {} triples", graph.len());
let shapes = parser::parse_shapes(&graph)?;
println!("{}", ShapesInfo::new(&shapes, graph.len(), detailed));
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn validate_command(
shapes_file: PathBuf,
data_files: Vec<PathBuf>,
data_format: Option<String>,
shapes_format: Option<String>,
output: Option<PathBuf>,
output_format: &str,
experimental_index: bool,
) -> Result<(), ShaclError> {
if experimental_index {
return validate_command_indexed(
shapes_file,
data_files,
data_format,
shapes_format,
output,
output_format,
);
}
let data_graphs_results: Vec<Result<(PathBuf, oxigraph::model::Graph), ShaclError>> =
data_files
.into_par_iter()
.map(|data_file| {
debug!(
"Reading data graph from {} with format {}",
data_file.display(),
data_format.as_deref().unwrap_or("auto")
);
let graph = read_graph_from_file(&data_file, data_format.as_deref())?;
info!(
"Data graph {} loaded with {} triples",
data_file.display(),
graph.len()
);
Ok((data_file, graph))
})
.collect();
let mut merged: Option<oxigraph::model::Graph> = None;
for data_graph_result in data_graphs_results {
let (data_file, graph) = data_graph_result?;
let triples = graph.len();
match &mut merged {
None => merged = Some(graph),
Some(data_graph) => {
let before_len = data_graph.len();
data_graph.extend(graph.iter());
info!(
"Merged data graph {} ({} triples, total now {})",
data_file.display(),
triples,
data_graph.len()
);
debug!(
"Data merge added {} unique triples",
data_graph.len().saturating_sub(before_len)
);
}
}
}
let data_graph = merged.unwrap_or_default();
debug!(
"Reading shapes graph from {} with format {}",
shapes_file.display(),
shapes_format.as_deref().unwrap_or("auto")
);
let shapes_graph = read_graph_from_file(&shapes_file, shapes_format.as_deref())?;
info!("Shapes graph loaded with {} triples", shapes_graph.len());
let validation_dataset = ValidationDataset::from_graphs(data_graph, shapes_graph)?;
let shapes = parser::parse_shapes(validation_dataset.shapes_graph())?;
info!("Parsed {} shapes", shapes.len());
let report = validate(&validation_dataset, &shapes);
emit_report(&report, output, output_format)
}
fn emit_report(
report: &shacl_rust::ValidationReport<'_>,
output: Option<PathBuf>,
output_format: &str,
) -> Result<(), ShaclError> {
let output_text = match output_format {
"text" => {
report.to_string()
}
"json" => {
report.as_json().to_string()
}
_ => {
use oxigraph::io::RdfFormat;
let normalized_format = if output_format.eq_ignore_ascii_case("turtle") {
"ttl"
} else {
output_format
};
let rdf_format = RdfFormat::from_extension(normalized_format).ok_or_else(|| {
ShaclError::Parse(format!(
"Unsupported output format: '{}'. Supported: text, json, yaml, ttl/turtle, nt, nq, rdf, jsonld, trig",
output_format
))
})?;
let report_graph = report.to_graph();
rdf::serialize_graph_to_string(&report_graph, rdf_format)?
}
};
if let Some(output_path) = output {
debug!("Writing report to {}", output_path.display());
std::fs::write(&output_path, &output_text)
.map_err(|e| ShaclError::Io(format!("Failed to write output file: {}", e)))?;
info!("Report written to {}", output_path.display());
} else {
println!("{}", output_text);
}
if !*report.get_conforms() {
std::process::exit(1);
}
Ok(())
}
fn validate_command_indexed(
shapes_file: PathBuf,
data_files: Vec<PathBuf>,
data_format: Option<String>,
shapes_format: Option<String>,
output: Option<PathBuf>,
output_format: &str,
) -> Result<(), ShaclError> {
let mut parsers = Vec::new();
for data_file in data_files {
let effective_format = data_format
.as_deref()
.or_else(|| data_file.extension().and_then(|ext| ext.to_str()))
.ok_or_else(|| {
ShaclError::Parse(format!(
"Could not infer RDF format for '{}'. Please provide --data-format.",
data_file.display()
))
})?
.to_string();
let file = std::fs::File::open(path_to_str(&data_file)?).map_err(|e| {
ShaclError::Io(format!(
"Failed to read graph file '{}': {}",
data_file.display(),
e
))
})?;
parsers.push(rdf::parse_triples_from_reader(file, &effective_format)?);
}
let shapes_graph = read_graph_from_file(&shapes_file, shapes_format.as_deref())?;
info!("Shapes graph loaded with {} triples", shapes_graph.len());
let parse_error: std::cell::RefCell<Option<ShaclError>> = std::cell::RefCell::new(None);
let triples = parsers
.into_iter()
.flatten()
.filter_map(|result| match result {
Ok(triple) => Some(triple),
Err(e) => {
parse_error.borrow_mut().get_or_insert(e);
None
}
});
let validation_dataset =
ValidationDataset::from_triples_with_experimental_index(triples, shapes_graph)?;
if let Some(e) = parse_error.into_inner() {
return Err(e);
}
info!(
"Data graph indexed with {} triples (experimental backend)",
validation_dataset.data().len()
);
let shapes = parser::parse_shapes(validation_dataset.shapes_graph())?;
info!("Parsed {} shapes", shapes.len());
let report = validate(&validation_dataset, &shapes);
emit_report(&report, output, output_format)
}
fn read_graph_from_file(
path: &Path,
format: Option<&str>,
) -> Result<oxigraph::model::Graph, ShaclError> {
let effective_format = format.or_else(|| path.extension().and_then(|ext| ext.to_str()));
let effective_format = effective_format.ok_or_else(|| {
ShaclError::Parse(format!(
"Could not infer RDF format for '{}'. Please provide --format.",
path.display()
))
})?;
let file = std::fs::File::open(path_to_str(path)?).map_err(|e| {
ShaclError::Io(format!(
"Failed to read graph file '{}': {}",
path.display(),
e
))
})?;
rdf::read_graph_from_reader(file, effective_format)
}