mod sarif;
use std::path::PathBuf;
use std::process;
fn or_die<T, E: std::fmt::Display>(result: Result<T, E>, context: &str) -> T {
result.unwrap_or_else(|e| {
eprintln!("{context}: {e}");
process::exit(1);
})
}
fn usage() -> ! {
eprintln!("Usage:");
eprintln!(" cimoxide-cli import [--json] <xml-files...>");
eprintln!(" cimoxide-cli convert --to json <xml-files...> [--out <output.json>]");
eprintln!(" cimoxide-cli convert --to xml <input.json> [--out <output.xml>]");
eprintln!(" cimoxide-cli convert --to xml <input.json> --profile EQ,SSH [--out <dir/>]");
eprintln!(" cimoxide-cli validate [--profiles EQ,SSH,...] [--solved] [--not-solved]");
eprintln!(" [--common] [--quality] [--silence rule1,rule2]");
eprintln!(" [--format text|json|sarif] <xml-files...>");
#[cfg(feature = "sparql")]
{
eprintln!(" cimoxide-cli query --query \"SELECT ...\" | --file <query.rq>");
eprintln!(" [--format text|json|csv|tsv] <xml-files...>");
}
process::exit(1);
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
usage();
}
if args[0] == "import" {
cmd_import(&args[1..]);
return;
}
if args[0] == "validate" {
cmd_validate(&args[1..]);
return;
}
if args[0] == "query" {
#[cfg(feature = "sparql")]
{
cmd_query(&args[1..]);
return;
}
#[cfg(not(feature = "sparql"))]
{
eprintln!("error: this build has no SPARQL support");
eprintln!("hint: rebuild with `cargo build -p cimoxide-cli --features sparql`");
process::exit(1);
}
}
if args[0] != "convert" {
usage();
}
let mut to_format: Option<String> = None;
let mut out_path: Option<PathBuf> = None;
let mut profile_str: Option<String> = None;
let mut input_files: Vec<PathBuf> = Vec::new();
let mut i = 1usize;
while i < args.len() {
match args[i].as_str() {
"--to" => {
i += 1;
if i >= args.len() {
eprintln!("error: --to requires an argument");
usage();
}
to_format = Some(args[i].clone());
}
"--out" => {
i += 1;
if i >= args.len() {
eprintln!("error: --out requires an argument");
usage();
}
out_path = Some(PathBuf::from(&args[i]));
}
"--profile" => {
i += 1;
if i >= args.len() {
eprintln!("error: --profile requires an argument");
usage();
}
profile_str = Some(args[i].clone());
}
arg if !arg.starts_with('-') => {
input_files.push(PathBuf::from(arg));
}
unknown => {
eprintln!("error: unknown flag: {unknown}");
usage();
}
}
i += 1;
}
let format = to_format.as_deref().unwrap_or_else(|| {
eprintln!("error: --to <json|xml> is required");
usage();
});
if input_files.is_empty() {
eprintln!("error: at least one input file is required");
usage();
}
match format {
"json" => cmd_to_json(&input_files, out_path.as_deref()),
"xml" => {
let profiles: Vec<&str> = profile_str
.as_deref()
.map(|s| s.split(',').collect())
.unwrap_or_default();
cmd_to_xml(&input_files[0], out_path.as_deref(), &profiles);
}
other => {
eprintln!("error: unknown format '{other}', expected 'json' or 'xml'");
usage();
}
}
}
fn cmd_to_json(xml_files: &[PathBuf], out: Option<&std::path::Path>) {
let paths: Vec<&std::path::Path> = xml_files.iter().map(PathBuf::as_path).collect();
let ds = or_die(cimmodel::CimDataset::decode_files_parallel(&paths), "error decoding XML");
let json = cimmodel::convert::dataset_to_json(&ds);
let text = or_die(serde_json::to_string_pretty(&json), "error serializing JSON");
write_output(&text, out);
}
fn cmd_to_xml(json_file: &std::path::Path, out: Option<&std::path::Path>, profiles: &[&str]) {
let src = or_die(
std::fs::read_to_string(json_file),
&format!("error reading {}", json_file.display()),
);
let ds = or_die(cimmodel::convert::dataset_from_json(&src), "error parsing JSON");
if profiles.is_empty() {
let xml = or_die(cimmodel::convert::dataset_to_xml(&ds), "error generating XML");
write_output(&xml, out);
return;
}
if profiles.len() > 1 {
let dir = out.unwrap_or_else(|| {
eprintln!("error: --out <dir/> is required when multiple --profile codes are given");
usage();
});
if !dir.is_dir() {
or_die(
std::fs::create_dir_all(dir),
&format!("error creating directory {}", dir.display()),
);
}
for &code in profiles {
let xml = or_die(
cimmodel::convert::dataset_to_xml_for_profile(&ds, code),
&format!("error generating XML for profile {code}"),
);
let path = dir.join(format!("{code}.xml"));
or_die(std::fs::write(&path, &xml), &format!("error writing {}", path.display()));
}
} else {
let code = profiles[0];
let xml = or_die(
cimmodel::convert::dataset_to_xml_for_profile(&ds, code),
&format!("error generating XML for profile {code}"),
);
write_output(&xml, out);
}
}
fn write_output(text: &str, out: Option<&std::path::Path>) {
match out {
Some(path) => or_die(std::fs::write(path, text), &format!("error writing {}", path.display())),
None => print!("{text}"),
}
}
fn cmd_import(args: &[String]) {
let mut input_files: Vec<PathBuf> = Vec::new();
let mut output_json = false;
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--json" => { output_json = true; }
arg if !arg.starts_with('-') => {
input_files.push(PathBuf::from(arg));
}
unknown => {
eprintln!("error: unknown flag: {unknown}");
usage();
}
}
i += 1;
}
if input_files.is_empty() {
eprintln!("error: at least one input file is required");
usage();
}
struct FileResult { name: String, count: usize }
let paths: Vec<&std::path::Path> = input_files.iter().map(PathBuf::as_path).collect();
let datasets = or_die(
cimmodel::CimDataset::decode_files_parallel_separate(&paths),
"error decoding input",
);
let mut per_file: Vec<FileResult> = Vec::new();
let mut combined = cimmodel::CimDataset::new();
for (path, ds) in input_files.iter().zip(datasets) {
let name = path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
per_file.push(FileResult { name, count: ds.entries.len() });
combined.merge(ds);
}
let total = combined.entries.len();
let mut type_counts: Vec<(String, usize)> = combined.by_type
.iter()
.map(|(t, v)| (t.clone(), v.len()))
.collect();
type_counts.sort_by(|a, b| a.0.cmp(&b.0));
if output_json {
let files_json: Vec<serde_json::Value> = per_file.iter()
.map(|f| serde_json::json!({ "file": f.name, "count": f.count }))
.collect();
let type_map: serde_json::Map<String, serde_json::Value> = type_counts.iter()
.map(|(t, n)| (t.clone(), serde_json::Value::from(*n)))
.collect();
println!("{}", serde_json::to_string_pretty(&serde_json::json!({
"total": total,
"files": files_json,
"type_counts": type_map,
})).unwrap());
} else {
println!("Total elements: {} (from {} file(s))\n", total, per_file.len());
for f in &per_file {
println!(" {}: {} elements", f.name, f.count);
}
if !type_counts.is_empty() {
println!("\nBy type:");
for (t, n) in &type_counts {
println!(" {:<50} {}", t, n);
}
}
}
}
fn cmd_validate(args: &[String]) {
let mut input_files: Vec<PathBuf> = Vec::new();
let mut profiles_override: Option<Vec<String>> = None;
let mut force_solved = false;
let mut force_not_solved = false;
let mut enable_common = false;
let mut enable_quality = false;
let mut silenced: Vec<String> = Vec::new();
let mut format = "text";
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--profiles" => {
i += 1;
if i >= args.len() { eprintln!("error: --profiles requires an argument"); usage(); }
profiles_override = Some(args[i].split(',').map(str::to_string).collect());
}
"--solved" => { force_solved = true; }
"--not-solved" => { force_not_solved = true; }
"--common" => { enable_common = true; }
"--quality" => { enable_quality = true; }
"--silence" => {
i += 1;
if i >= args.len() { eprintln!("error: --silence requires an argument"); usage(); }
silenced.extend(args[i].split(',').map(str::to_string));
}
"--format" => {
i += 1;
if i >= args.len() { eprintln!("error: --format requires an argument"); usage(); }
format = match args[i].as_str() {
"json" => "json",
"text" => "text",
"sarif" => "sarif",
other => { eprintln!("error: unknown format '{other}', expected 'text', 'json' or 'sarif'"); usage(); }
};
}
arg if !arg.starts_with('-') => {
input_files.push(PathBuf::from(arg));
}
unknown => {
eprintln!("error: unknown flag: {unknown}");
usage();
}
}
i += 1;
}
if input_files.is_empty() {
eprintln!("error: at least one input file is required");
usage();
}
let paths: Vec<&std::path::Path> = input_files.iter().map(PathBuf::as_path).collect();
let datasets = or_die(
cimmodel::CimDataset::decode_files_parallel_separate(&paths),
"error decoding input",
);
let solved_override = if force_solved {
Some(true)
} else if force_not_solved {
Some(false)
} else {
None
};
let cfg = cimvalidation::combined_config(
&datasets,
profiles_override,
solved_override,
enable_common,
enable_quality,
silenced,
);
let violations = cimvalidation::validate_files(datasets, &cfg);
if format == "sarif" {
let log = sarif::render(&violations, &input_files);
let mut out = std::io::BufWriter::new(std::io::stdout().lock());
or_die(serde_json::to_writer_pretty(&mut out, &log), "error writing SARIF");
or_die(std::io::Write::write_all(&mut out, b"\n"), "error writing SARIF");
} else if format == "json" {
let arr: Vec<serde_json::Value> = violations.iter().map(|v| {
serde_json::json!({
"object_id": v.object_id,
"rule_id": v.rule_id,
"name": v.name,
"class": v.class,
"property": v.property,
"message": v.message,
"severity": v.severity,
})
}).collect();
println!("{}", serde_json::to_string_pretty(&arr).unwrap());
} else if violations.is_empty() {
println!("No violations found.");
} else {
for v in &violations {
println!("[{}] {} — {} ({})", v.severity, v.rule_id, v.message, v.object_id);
}
let failures = violations.iter().filter(|v| v.severity != "sh:Info").count();
let advisory = violations.len() - failures;
if advisory > 0 {
eprintln!("{failures} violation(s), {advisory} advisory (sh:Info).");
} else {
eprintln!("{failures} violation(s) found.");
}
if failures > 0 {
process::exit(2);
}
}
}
#[cfg(feature = "sparql")]
fn cmd_query(args: &[String]) {
use cimsparql::format::Format;
let mut query: Option<String> = None;
let mut query_file: Option<PathBuf> = None;
let mut format = Format::Text;
let mut input_files: Vec<PathBuf> = Vec::new();
let mut i = 0usize;
while i < args.len() {
match args[i].as_str() {
"--query" | "-q" => {
i += 1;
if i >= args.len() {
eprintln!("error: --query requires an argument");
usage();
}
query = Some(args[i].clone());
}
"--file" | "-f" => {
i += 1;
if i >= args.len() {
eprintln!("error: --file requires an argument");
usage();
}
query_file = Some(PathBuf::from(&args[i]));
}
"--format" => {
i += 1;
if i >= args.len() {
eprintln!("error: --format requires an argument");
usage();
}
format = match Format::parse(&args[i]) {
Some(f) => f,
None => {
eprintln!("error: unknown format: {} (want text|json|csv|tsv)", args[i]);
process::exit(1);
}
};
}
arg if !arg.starts_with('-') => input_files.push(PathBuf::from(arg)),
unknown => {
eprintln!("error: unknown flag: {unknown}");
usage();
}
}
i += 1;
}
let sparql = match (query, query_file) {
(Some(_), Some(_)) => {
eprintln!("error: pass either --query or --file, not both");
process::exit(1);
}
(Some(q), None) => q,
(None, Some(path)) => or_die(std::fs::read_to_string(&path), "error reading query file"),
(None, None) => {
eprintln!("error: a query is required (--query or --file)");
usage();
}
};
if input_files.is_empty() {
eprintln!("error: at least one input file is required");
usage();
}
let paths: Vec<&std::path::Path> = input_files.iter().map(PathBuf::as_path).collect();
let dataset = or_die(
cimmodel::CimDataset::decode_files_parallel(&paths),
"error decoding input",
);
let store = or_die(cimsparql::CimStore::from_dataset(&dataset), "error building RDF graph");
let results = or_die(store.query(&sparql), "query failed");
let stdout = std::io::stdout();
let mut out = stdout.lock();
or_die(cimsparql::format::write(results, format, &mut out), "error writing results");
}