pub mod rules;
use std::path::Path;
use serde_json::{json, Value};
use crate::h5::attrs;
use crate::h5::ops;
use crate::integrity::AttrNameMap;
use crate::json::{repr_list, repr_str};
use crate::sample::reader::PROFILES;
use crate::{Error, Result};
pub const LEVELS: [&str; 4] = ["structural", "semantic", "integrity", "strict"];
pub const SAMPLES_GROUP: &str = "samples";
#[derive(Debug, Clone, PartialEq)]
pub struct Diagnostic {
pub code: String,
pub location: String,
pub message: String,
pub severity: String,
pub level: String,
}
impl Diagnostic {
pub fn summary(&self) -> &'static str {
crate::codes::get(&self.code).map(|c| c.summary.as_str()).unwrap_or("")
}
pub fn to_json(&self) -> Value {
json!({
"code": self.code,
"severity": self.severity,
"location": self.location,
"message": self.message,
"level": self.level,
})
}
pub fn line(&self) -> String {
format!("{:7} {} {}: {}", self.severity.to_uppercase(), self.code, self.location, self.message)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Report {
pub path: String,
pub level: String,
pub profiles: Vec<String>,
pub diagnostics: Vec<Diagnostic>,
pub checked: Value,
}
impl Report {
pub fn new(path: &str, level: &str) -> Report {
Report {
path: path.into(),
level: level.into(),
profiles: Vec::new(),
diagnostics: Vec::new(),
checked: json!({}),
}
}
pub fn effective_severity<'a>(&self, d: &'a Diagnostic) -> &'a str {
if self.level == "strict" {
"error"
} else {
&d.severity
}
}
pub fn errors(&self) -> Vec<&Diagnostic> {
self.diagnostics.iter().filter(|d| self.effective_severity(d) == "error").collect()
}
pub fn warnings(&self) -> Vec<&Diagnostic> {
self.diagnostics.iter().filter(|d| self.effective_severity(d) == "warning").collect()
}
pub fn promoted(&self) -> Vec<&Diagnostic> {
if self.level != "strict" {
return Vec::new();
}
self.diagnostics.iter().filter(|d| d.severity == "warning").collect()
}
pub fn codes(&self) -> Vec<String> {
let mut out: Vec<String> = self.diagnostics.iter().map(|d| d.code.clone()).collect();
out.sort();
out.dedup();
out
}
pub fn ok(&self) -> bool {
self.errors().is_empty()
}
pub fn to_json(&self) -> Value {
json!({
"path": self.path,
"level": self.level,
"profiles": self.profiles,
"ok": self.ok(),
"errors": self.errors().len(),
"warnings": self.warnings().len(),
"promoted": self.promoted().len(),
"diagnostics": self.diagnostics.iter().map(Diagnostic::to_json).collect::<Vec<_>>(),
"checked": self.checked,
})
}
pub fn format(&self, verbose: bool) -> String {
let profiles = if self.profiles.is_empty() { "-".to_string() } else { self.profiles.join(",") };
let mut lines = vec![format!(
"{}: {} [{}] profiles={profiles} ({} errors, {} warnings)",
self.path,
if self.ok() { "OK" } else { "FAILED" },
self.level,
self.errors().len(),
self.warnings().len()
)];
for d in &self.diagnostics {
lines.push(format!(" {}", d.line()));
let summary = d.summary();
if verbose && !summary.is_empty() {
lines.push(format!(" -> {summary}"));
}
}
lines.join("\n")
}
pub fn repr(&self) -> String {
format!(
"Report({}, ok={}, {} errors, {} warnings)",
repr_str(&self.path),
if self.ok() { "True" } else { "False" },
self.errors().len(),
self.warnings().len()
)
}
fn sort(&mut self) {
self.diagnostics.sort_by(|a, b| {
((a.severity != "error"), &a.code, &a.location).cmp(&((b.severity != "error"), &b.code, &b.location))
});
}
}
pub fn merge(reports: &[Report], path: &str) -> Report {
let mut out = Report::new(path, reports.first().map(|r| r.level.as_str()).unwrap_or("structural"));
for report in reports {
for d in &report.diagnostics {
out.diagnostics.push(Diagnostic { location: format!("{}:{}", report.path, d.location), ..d.clone() });
}
}
out
}
#[doc(hidden)]
pub fn check_level(level: &str) -> Result<()> {
if !LEVELS.contains(&level) {
return Err(Error::Value(format!(
"unknown validation level {}; expected one of {}",
repr_str(level),
crate::json::repr_list(&LEVELS).replacen('[', "(", 1).replacen(']', ")", 1)
)));
}
Ok(())
}
fn check_required(profiles: Option<&[String]>) -> Result<()> {
let mut unknown: Vec<&String> =
profiles.unwrap_or_default().iter().filter(|p| !PROFILES.contains(&p.as_str())).collect();
if !unknown.is_empty() {
unknown.sort();
unknown.dedup();
return Err(Error::Value(format!(
"unknown profile(s) {}; expected any of {}",
repr_list(&unknown),
repr_list(&PROFILES)
)));
}
Ok(())
}
fn run_rules(ctx: &mut rules::Context, rules: &[(&'static str, rules::Rule)]) -> (Vec<Diagnostic>, Vec<&'static str>) {
let mut out = Vec::new();
let mut run = Vec::new();
for (name, rule) in rules {
run.push(*name);
let found = match rule(ctx) {
Ok(found) => found,
Err(e) => vec![match e.code() {
Some(code) => ctx.err(code, "/", format!("{name} stopped: {}", e.message())),
None => ctx.err("E001", "/", format!("{name} could not read the file: {}", e.python_line())),
}],
};
let unsupported = found.iter().any(|d| d.code == "E002");
out.extend(found);
if unsupported {
break;
}
}
(out, run)
}
pub fn validate_root_with(
root: &hdf5::Group,
path: &str,
level: &str,
profiles: Option<&[String]>,
attr_names: Option<&AttrNameMap>,
errors_only: bool,
) -> Result<Report> {
check_level(level)?;
check_required(profiles)?;
let mut declared: Vec<String> = attrs::get_strs(root, "medh5_profiles").ok().flatten().unwrap_or_default();
for profile in profiles.unwrap_or_default() {
if !declared.contains(profile) {
declared.push(profile.clone());
}
}
let mut ctx = rules::Context::new(root.clone(), path, level, declared.clone(), errors_only);
ctx.attr_names = attr_names.cloned();
let mut report = Report::new(path, level);
report.profiles = declared;
let (diagnostics, run) = run_rules(&mut ctx, &rules::rules_for(level));
report.diagnostics = diagnostics;
report.checked = json!({
"rules": run,
"schema_checked": ctx.schema_checked,
});
report.sort();
Ok(report)
}
pub fn validate_root(root: &hdf5::Group, path: Option<&Path>, level: &str, errors_only: bool) -> Result<Report> {
let shown = path.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "<memory>".into());
validate_root_with(root, &shown, level, None, None, errors_only)
}
#[doc(hidden)]
pub fn attr_names_for(root: &hdf5::Group, level: &str) -> Option<AttrNameMap> {
if level != "integrity" && level != "strict" {
return None;
}
crate::sample::attr_name_map_of(root).ok()
}
pub fn validate_collection(root: &hdf5::Group, path: &str, level: &str, profiles: Option<&[String]>) -> Result<Report> {
check_level(level)?;
check_required(profiles)?;
let mut ctx = rules::Context::new(root.clone(), path, level, Vec::new(), false);
let header = rules::check_collection(&mut ctx)?;
let unsupported = header.iter().any(|d| d.code == "E002");
let mut members = Vec::new();
if let Some(node) = ops::child_group(root, SAMPLES_GROUP).filter(|_| !unsupported) {
for key in ops::members(&node)? {
if let Some(member) = ops::child_group(&node, &key) {
let names = attr_names_for(&member, level);
members.push(validate_root_with(
&member,
&format!("/{SAMPLES_GROUP}/{key}"),
level,
profiles,
names.as_ref(),
false,
)?);
}
}
}
let mut combined = if members.is_empty() { Report::new(path, level) } else { merge(&members, path) };
combined.level = level.into();
let mut diagnostics = header;
diagnostics.extend(combined.diagnostics);
combined.diagnostics = diagnostics;
combined.checked = json!({"samples": members.iter().map(|r| r.path.clone()).collect::<Vec<_>>()});
combined.sort();
Ok(combined)
}
fn read_failure(path: &str, level: &str, message: String) -> Report {
let mut report = Report::new(path, level);
report.diagnostics.push(Diagnostic {
code: "E001".into(),
location: "/".into(),
message,
severity: "error".into(),
level: level.into(),
});
report
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidateOptions {
pub level: String,
pub profiles: Option<Vec<String>>,
pub codes_revision: Option<u32>,
}
impl Default for ValidateOptions {
fn default() -> Self {
ValidateOptions { level: "semantic".into(), profiles: None, codes_revision: None }
}
}
impl ValidateOptions {
pub fn level(mut self, level: &str) -> Self {
self.level = level.into();
self
}
pub fn profiles(mut self, profiles: &[String]) -> Self {
self.profiles = Some(profiles.to_vec());
self
}
pub fn codes_revision(mut self, revision: u32) -> Self {
self.codes_revision = Some(revision);
self
}
}
fn check_revision(revision: Option<u32>) -> Result<()> {
match revision {
Some(r) if r < 1 || r > crate::codes::table_revision() => Err(Error::invalid(format!(
"codes revision {r} is not a revision of the code table; this engine implements revisions 1 to {} \
(§15.2)",
crate::codes::table_revision()
))),
_ => Ok(()),
}
}
fn pin_revision(report: &mut Report, revision: Option<u32>) {
if let Some(pinned) = revision {
report.diagnostics.retain(|d| crate::codes::revision(&d.code).is_none_or(|r| r <= pinned));
}
}
pub fn validate_file(path: &Path, level: &str, profiles: Option<&[String]>) -> Result<Report> {
let mut options = ValidateOptions::default().level(level);
options.profiles = profiles.map(<[String]>::to_vec);
validate_file_with(path, &options)
}
pub fn validate_file_with(path: &Path, options: &ValidateOptions) -> Result<Report> {
let (level, profiles) = (options.level.as_str(), options.profiles.as_deref());
check_level(level)?;
check_required(profiles)?;
check_revision(options.codes_revision)?;
let text = path.to_string_lossy().into_owned();
let handle = match crate::h5::file::open_read(path) {
Ok(h) => h,
Err(e) => return Ok(read_failure(&text, level, e.to_string())),
};
let result = (|| -> Result<Report> {
let root = handle.as_group()?;
let kind = attrs::get_str(&root, "medh5_kind")?.unwrap_or_else(|| "sample".into());
if kind == "collection" {
return validate_collection(&root, &text, level, profiles);
}
let names = attr_names_for(&root, level);
validate_root_with(&root, &text, level, profiles, names.as_ref(), false)
})();
let mut report = match result {
Ok(r) => r,
Err(e) => read_failure(&text, level, format!("the file could not be read: {}", e.python_line())),
};
pin_revision(&mut report, options.codes_revision);
Ok(report)
}
pub fn clinical_checked(root: &hdf5::Group, path: &str) -> Result<(Vec<Diagnostic>, Option<rules::ClinicalTables>)> {
let declared = attrs::get_strs(root, "medh5_profiles")?.unwrap_or_default();
let mut ctx = rules::Context::new(root.clone(), path, "semantic", declared, true);
let mut out = Vec::new();
for rule in [rules::check_document as rules::Rule, rules::check_clinical, rules::check_clinical_records] {
out.extend(rule(&mut ctx)?);
}
out.retain(|d| d.severity == "error" && (d.code.starts_with("E8") || d.code == "E009" || d.code == "E004"));
Ok((out, ctx.clinical.take()))
}
pub fn validate_paths(paths: &[&Path], level: &str, profiles: Option<&[String]>) -> Result<Vec<Report>> {
paths.iter().map(|p| validate_file(p, level, profiles)).collect()
}
pub fn validate_paths_with(paths: &[&Path], options: &ValidateOptions) -> Result<Vec<Report>> {
check_revision(options.codes_revision)?;
paths.iter().map(|p| validate_file_with(p, options)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn stops_on_a_defect(_: &mut rules::Context) -> Result<Vec<Diagnostic>> {
Err(Error::coded("E405", "6 instance ids for 3 class ids"))
}
fn cannot_read(_: &mut rules::Context) -> Result<Vec<Diagnostic>> {
Err(Error::Io("H5Dread failed".into()))
}
fn finds_one(ctx: &mut rules::Context) -> Result<Vec<Diagnostic>> {
Ok(vec![ctx.err("W903", "/meta#deidentification", "no de-identification record")])
}
#[test]
fn s15_2_a_rule_that_stops_reports_its_own_code_and_the_rest_still_run() {
let dir = tempfile::tempdir().unwrap();
let file = crate::h5::file::marked(|| hdf5::File::create(dir.path().join("x.medh5"))).unwrap();
let mut ctx = rules::Context::new(file.as_group().unwrap(), "x.medh5", "semantic", Vec::new(), false);
let rules: [(&'static str, rules::Rule); 3] =
[("check_a", stops_on_a_defect), ("check_b", cannot_read), ("check_c", finds_one)];
let (found, run) = run_rules(&mut ctx, &rules);
assert_eq!(run, ["check_a", "check_b", "check_c"]);
let codes: Vec<&str> = found.iter().map(|d| d.code.as_str()).collect();
assert_eq!(codes, ["E405", "E001", "W903"]);
assert_eq!(found[0].message, "check_a stopped: 6 instance ids for 3 class ids");
assert_eq!((found[0].severity.as_str(), found[0].level.as_str()), ("error", "semantic"));
assert!(found[1].message.starts_with("check_b could not read the file: "), "{}", found[1].message);
assert_eq!(found[2].severity, "warning");
}
#[test]
fn s15_2_an_object_that_cannot_be_read_is_e001_where_it_is() {
let dir = tempfile::tempdir().unwrap();
let file = crate::h5::file::marked(|| hdf5::File::create(dir.path().join("x.medh5"))).unwrap();
let ctx = rules::Context::new(file.as_group().unwrap(), "x.medh5", "semantic", Vec::new(), false);
let defect = ctx.failed("/grids/g", &Error::coded("E104", "spacing (0.0, 1.0) must be finite and > 0"));
assert_eq!((defect.code.as_str(), defect.location.as_str()), ("E104", "/grids/g"));
assert_eq!(defect.message, "spacing (0.0, 1.0) must be finite and > 0");
let unread = ctx.failed("/grids/g", &Error::Io("H5Aread failed".into()));
assert_eq!((unread.code.as_str(), unread.location.as_str()), ("E001", "/grids/g"));
assert!(unread.message.starts_with("could not be read: "), "{}", unread.message);
}
#[test]
fn s15_2_a_revision_outside_the_table_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("absent.medh5");
for revision in [0, crate::codes::table_revision() + 1] {
let options = ValidateOptions::default().codes_revision(revision);
let err = validate_file_with(&path, &options).unwrap_err();
assert_eq!(err.kind_name(), "MEDH5ValidationError", "{err:?}");
assert!(err.message().contains(&format!("codes revision {revision}")), "{err}");
assert!(validate_paths_with(&[path.as_path()], &options).is_err());
}
let pinned = ValidateOptions::default().codes_revision(crate::codes::table_revision());
let report = validate_file_with(&path, &pinned).unwrap();
assert_eq!(report.codes(), ["E001"]);
}
}