use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use clap::{Args, Parser, Subcommand};
use serde::Serialize;
use serde_json::json;
use tempfile::TempDir;
use wsi_annotation_tests::probe::ViewerProbe;
use wsi_annotation_tests::shim::ReferenceShim;
use wsi_annotation_tests::validators::{ValidatorStatus, run_standard_validators};
use wsi_annotation_tests::{run_core_profile, run_full_profile};
#[derive(Parser)]
#[command(
name = "wsi-annotation-tests",
version,
about = "DICOM Annotation Test Suite: Neutral DICOM WSI ANN/SEG/SR/PM interoperability harness"
)]
struct Cli {
#[arg(long, global = true, default_value = ".venv/bin/python")]
reference_python: PathBuf,
#[arg(long, global = true, default_value = "shim/reference_shim.py")]
reference_shim: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
QualifyPydcm {
#[arg(long)]
source: Option<PathBuf>,
#[arg(long)]
ann: Option<PathBuf>,
#[arg(long)]
seg: Option<PathBuf>,
},
GenerateFixtures {
#[arg(long)]
output: PathBuf,
},
Validate {
#[arg(
long = "dicom-edition",
visible_alias = "edition",
default_value = "2026c"
)]
edition: String,
#[arg(long, default_value_t = 300.0)]
timeout: f64,
#[arg(required = true)]
files: Vec<PathBuf>,
},
RunCore(ProfileArguments),
RunFull(FullArguments),
}
#[derive(Args)]
struct ProfileArguments {
#[arg(long)]
probe: PathBuf,
#[arg(long)]
results: PathBuf,
#[arg(long)]
run_id: Option<String>,
}
#[derive(Args)]
struct FullArguments {
#[command(flatten)]
profile: ProfileArguments,
#[arg(
long = "dicom-edition",
visible_alias = "edition",
default_value = "2026c"
)]
edition: String,
#[arg(long)]
orthanc: Option<PathBuf>,
#[arg(long = "orthanc-plugin")]
orthanc_plugins: Vec<PathBuf>,
}
fn main() -> ExitCode {
match execute(Cli::parse()) {
Ok(code) => ExitCode::from(code),
Err(error) => {
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
fn execute(cli: Cli) -> Result<u8, String> {
let reference_python = project_path(&cli.reference_python);
let reference_shim = project_path(&cli.reference_shim);
let reference = ReferenceShim::from_program(
reference_python.as_os_str(),
vec![reference_shim.as_os_str().to_owned()],
Duration::from_mins(10),
)?;
match cli.command {
Command::QualifyPydcm { source, ann, seg } => {
let temporary;
let (source, ann, seg) = match (source, ann, seg) {
(Some(source), Some(ann), Some(seg)) => (source, ann, seg),
(None, None, None) => {
temporary = TempDir::new()
.map_err(|error| format!("could not create fixture directory: {error}"))?;
let fixtures = reference
.generate_core(temporary.path())
.map_err(|error| error.to_string())?;
(
fixtures.source,
fixtures.ann["2D_VOLUME"].clone(),
fixtures.seg["BINARY"].clone(),
)
}
_ => {
return Err("--source, --ann, and --seg must be supplied together".to_owned());
}
};
let qualification = reference
.qualify_pydcm(&source, &ann, &seg)
.map_err(|error| error.to_string())?;
print_json(&qualification)?;
Ok(0)
}
Command::GenerateFixtures { output } => {
let fixtures = reference
.generate_core(&output)
.map_err(|error| error.to_string())?;
print_json(&fixtures)?;
Ok(0)
}
Command::Validate {
edition,
timeout,
files,
} => {
if !timeout.is_finite() || timeout <= 0.0 {
return Err("validator timeout must be positive and finite".to_owned());
}
let observations =
run_standard_validators(&files, &edition, Duration::from_secs_f64(timeout))
.map_err(|error| error.to_string())?;
let passed = observations
.iter()
.all(|observation| observation.status == ValidatorStatus::Passed);
print_json(&observations)?;
Ok(u8::from(!passed))
}
Command::RunCore(arguments) => {
let probe = viewer_probe(&arguments.probe)?;
let run_id = arguments.run_id.map_or_else(default_run_id, Ok)?;
let result = run_core_profile(&reference, &probe, &arguments.results, &run_id)?;
print_json(&json!({
"manifest": result.manifest,
"status": if result.ok { "passed" } else { "failed" },
}))?;
Ok(u8::from(!result.ok))
}
Command::RunFull(arguments) => {
let probe = viewer_probe(&arguments.profile.probe)?;
let run_id = arguments.profile.run_id.map_or_else(default_run_id, Ok)?;
let result = run_full_profile(
&reference,
&probe,
&arguments.profile.results,
&run_id,
&arguments.edition,
arguments.orthanc.as_deref(),
&arguments.orthanc_plugins,
)?;
print_json(&json!({
"manifest": result.manifest,
"status": if result.ok { "passed" } else { "failed" },
}))?;
Ok(u8::from(!result.ok))
}
}
}
fn viewer_probe(path: &Path) -> Result<ViewerProbe, String> {
ViewerProbe::from_program(path.as_os_str(), Vec::new(), Some(Duration::from_mins(10)))
}
fn project_path(path: &Path) -> PathBuf {
if path.is_absolute() || path.exists() {
path.to_path_buf()
} else {
Path::new(env!("CARGO_MANIFEST_DIR")).join(path)
}
}
fn default_run_id() -> Result<String, String> {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("system clock precedes Unix epoch: {error}"))?
.as_secs();
Ok(format!("run-{seconds}"))
}
fn print_json(value: &impl Serialize) -> Result<(), String> {
println!(
"{}",
serde_json::to_string(value)
.map_err(|error| format!("could not serialize command report: {error}"))?
);
Ok(())
}