use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use ontoindex_catalog::{CatalogStats, IndexBuilder, OntologyCatalog};
use ontoindex_query::{
query_catalog,
sparql::to_json as sparql_to_json,
sparql_catalog,
sql::{to_csv as sql_to_csv, to_json as sql_to_json},
};
use ontoindex_reasoner::{classify, explain, ExplanationRequest, ReasonerId, WorkspaceInputLoader};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "ontoindex",
version,
about = "Local-first ontology index and query engine (OntoCode v0.6)"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Index {
#[arg(default_value = ".")]
workspace: PathBuf,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
Query {
workspace: PathBuf,
sql: String,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
Sparql {
workspace: PathBuf,
query: String,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
Validate {
#[arg(default_value = ".")]
workspace: PathBuf,
},
Inspect {
#[arg(default_value = ".")]
workspace: PathBuf,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
Patch {
document: PathBuf,
patch_file: PathBuf,
#[arg(long)]
preview: bool,
},
Classify {
#[arg(default_value = ".")]
workspace: PathBuf,
#[arg(long, default_value = "el")]
profile: String,
#[arg(long, default_value_t = true)]
auto_profile: bool,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
Explain {
#[arg(default_value = ".")]
workspace: PathBuf,
#[arg(long)]
class: String,
#[arg(long, default_value = "el")]
profile: String,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum OutputFormat {
Text,
Json,
Csv,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Index { workspace, format } => {
let catalog = build_catalog(&workspace)?;
print_stats(&catalog.data().stats(), format)?;
}
Commands::Query { workspace, sql, format } => {
let catalog = build_catalog(&workspace)?;
let result = query_catalog(&catalog, &sql).context("query failed")?;
print_query_result(&result.columns, &result.rows, format)?;
}
Commands::Sparql { workspace, query, format } => {
let catalog = build_catalog(&workspace)?;
let result = sparql_catalog(&catalog, &query).context("sparql failed")?;
match format {
OutputFormat::Json => println!("{}", sparql_to_json(&result)?),
_ => print_query_result(&result.columns, &result.rows, format)?,
}
}
Commands::Validate { workspace } => {
let catalog = build_catalog(&workspace)?;
let data = catalog.data();
let mut error_count = 0usize;
let mut warning_count = 0usize;
for diag in &data.diagnostics {
match diag.severity {
ontoindex_core::DiagnosticSeverity::Error => {
error_count += 1;
eprintln!(
"ERROR [{}] {}:{}:{}: {}",
diag.code.as_str(),
diag.file.display(),
diag.range.line.unwrap_or(0),
diag.range.column.unwrap_or(0),
diag.message
);
}
ontoindex_core::DiagnosticSeverity::Warning => {
warning_count += 1;
eprintln!(
"WARN [{}] {}:{}:{}: {}",
diag.code.as_str(),
diag.file.display(),
diag.range.line.unwrap_or(0),
diag.range.column.unwrap_or(0),
diag.message
);
}
ontoindex_core::DiagnosticSeverity::Info => {
eprintln!(
"INFO [{}] {}: {}",
diag.code.as_str(),
diag.file.display(),
diag.message
);
}
}
}
if error_count > 0 {
bail!("validation failed with {error_count} error(s), {warning_count} warning(s)");
}
println!(
"OK: indexed {} ontology file(s), {} warning(s)",
data.stats().ontology_count,
warning_count
);
}
Commands::Inspect { workspace, format } => {
let catalog = build_catalog(&workspace)?;
print_stats(&catalog.data().stats(), format)?;
}
Commands::Patch { document, patch_file, preview } => {
let patches: Vec<ontoindex_owl::PatchOp> =
serde_json::from_slice(&std::fs::read(&patch_file)?)
.context("failed to parse patch JSON")?;
let catalog = IndexBuilder::new()
.workspace(document.parent().unwrap_or(std::path::Path::new(".")))
.build()
.ok();
let namespaces = catalog
.as_ref()
.and_then(|c| {
c.data().documents.iter().find(|d| {
d.path.canonicalize().ok().as_ref() == document.canonicalize().ok().as_ref()
})
})
.map(|d| d.namespaces.clone())
.unwrap_or_default();
let result = ontoindex_owl::apply_patches(&document, &patches, preview, &namespaces)
.context("patch failed")?;
println!("{}", serde_json::to_string_pretty(&result)?);
if !preview && result.applied {
println!("applied");
}
}
Commands::Classify { workspace, profile, auto_profile, format } => {
let result = run_classify(&workspace, &profile, auto_profile)?;
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&result)?),
OutputFormat::Text | OutputFormat::Csv => {
println!("profile: {}", result.profile_used);
println!("consistent: {}", result.consistent);
println!("unsatisfiable: {}", result.unsatisfiable.len());
println!("inferred_edges: {}", result.inferred.edges.len());
println!("new_inferences: {}", result.new_inferences.len());
println!("duration_ms: {}", result.duration_ms);
for iri in &result.unsatisfiable {
println!("UNSAT {iri}");
}
for edge in &result.new_inferences {
println!("INFERRED {} SubClassOf {}", edge.child, edge.parent);
}
}
}
if !result.consistent {
bail!(
"classification found {} unsatisfiable class(es)",
result.unsatisfiable.len()
);
}
}
Commands::Explain { workspace, class, profile, format } => {
let result = run_explain(&workspace, &class, &profile)?;
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&result)?),
OutputFormat::Text | OutputFormat::Csv => {
println!("class: {}", result.class_iri);
println!("{}", result.text);
}
}
}
}
Ok(())
}
fn build_catalog(workspace: &PathBuf) -> Result<OntologyCatalog> {
IndexBuilder::new()
.workspace(workspace)
.build()
.with_context(|| format!("failed to index workspace {}", workspace.display()))
}
fn load_reasoner_input(workspace: &PathBuf) -> Result<ontoindex_reasoner::ReasonerInput> {
let catalog = build_catalog(workspace)?;
WorkspaceInputLoader::new(workspace)
.load(catalog.class_hierarchy())
.map_err(|e| anyhow::anyhow!(e))
}
fn run_classify(
workspace: &PathBuf,
profile: &str,
auto_profile: bool,
) -> Result<ontoindex_reasoner::ClassificationResult> {
let profile_id = ReasonerId::parse(profile).map_err(|e| anyhow::anyhow!(e))?;
let input = load_reasoner_input(workspace)?;
classify(profile_id, &input, auto_profile).map_err(|e| anyhow::anyhow!(e))
}
fn run_explain(
workspace: &PathBuf,
class: &str,
profile: &str,
) -> Result<ontoindex_reasoner::ExplanationResult> {
let profile_id = ReasonerId::parse(profile).map_err(|e| anyhow::anyhow!(e))?;
let input = load_reasoner_input(workspace)?;
explain(profile_id, &input, &ExplanationRequest { class_iri: class.to_string() })
.map_err(|e| anyhow::anyhow!(e))
}
fn print_stats(stats: &CatalogStats, format: OutputFormat) -> Result<()> {
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(stats)?),
OutputFormat::Csv | OutputFormat::Text => {
println!("ontologies: {}", stats.ontology_count);
println!("classes: {}", stats.class_count);
println!("object_properties: {}", stats.object_property_count);
println!("data_properties: {}", stats.data_property_count);
println!("annotation_properties: {}", stats.annotation_property_count);
println!("individuals: {}", stats.individual_count);
println!("axioms: {}", stats.axiom_count);
println!("annotations: {}", stats.annotation_count);
println!("triples: {}", stats.triple_count);
println!("parse_errors: {}", stats.error_count);
println!("diagnostic_errors: {}", stats.diagnostic_error_count);
println!("diagnostic_warnings: {}", stats.diagnostic_warning_count);
}
}
Ok(())
}
fn print_query_result(
columns: &[String],
rows: &[std::collections::BTreeMap<String, String>],
format: OutputFormat,
) -> Result<()> {
let result = ontoindex_query::sql::QueryResult {
columns: columns.to_vec(),
rows: rows.to_vec(),
truncated: false,
};
match format {
OutputFormat::Json => println!("{}", sql_to_json(&result)?),
OutputFormat::Csv => print!("{}", sql_to_csv(&result)?),
OutputFormat::Text => {
if columns.is_empty() {
println!("(no columns)");
return Ok(());
}
println!("{}", columns.join("\t"));
for row in rows {
let line: Vec<String> =
columns.iter().map(|c| row.get(c).cloned().unwrap_or_default()).collect();
println!("{}", line.join("\t"));
}
}
}
Ok(())
}