sbol 0.1.0

Rust implementation of the SBOL 3.1.0 specification.
Documentation
//! Generates the validation rule catalog from `rules.toml`.
//!
//! Cold-compile cost on a fresh `target/` dir is roughly 30s because of
//! the `toml`+`serde` dep stack. Subsequent builds reuse the cached
//! deps and only re-run the script when `rules.toml` or `build.rs`
//! changes (see `cargo:rerun-if-changed` directives at the bottom of
//! `main`).
//!
//! Outputs into `OUT_DIR`:
//!   - `rule_catalog.rs` — `VALIDATION_RULE_STATUSES` slice literal,
//!     sorted by rule id for diff stability.
//!   - `rule_spec_meta.rs` — the `VALIDATION_RULE_SPEC_*` constants
//!     sourced from the TOML `[meta]` block.
//!
//! Failures (TOML parse error, unknown enum variant, missing required
//! field) panic with a message that names the offending rule id and
//! field so the operator can find the bad entry without reading serde
//! line numbers.

use std::fs;
use std::path::{Path, PathBuf};

use serde::Deserialize;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Catalog {
    meta: Meta,
    #[serde(default, rename = "rule")]
    rules: Vec<RawRule>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Meta {
    spec_version: String,
    spec_path: String,
    spec_canonical_url: String,
    spec_pdf_sha256: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawRule {
    id: String,
    status: String,
    normative_severity: String,
    spec_section: String,
    note: String,
    #[serde(default)]
    blocker: Option<String>,
    #[serde(default)]
    validator_function: Option<String>,
    /// Optional per-rule coverage tag. When set, overrides the
    /// default inferred from `status`/`blocker`.
    #[serde(default)]
    coverage_kind: Option<String>,
}

fn main() {
    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let rules_path = manifest_dir.join("rules.toml");
    let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR set by cargo"));

    let text = match fs::read_to_string(&rules_path) {
        Ok(t) => t,
        Err(err) => panic!("failed to read {}: {err}", rules_path.display()),
    };
    let catalog: Catalog = match toml::from_str(&text) {
        Ok(c) => c,
        Err(err) => panic!("failed to parse {}: {err}", rules_path.display()),
    };

    let mut rules = catalog.rules;
    rules.sort_by(|a, b| a.id.cmp(&b.id));
    // The policies dir lives in the workspace, not the crate, so it is
    // absent when building from a packaged crates.io tarball. The check
    // is a maintainer-side guard against rules.toml drift; skip it when
    // the dir isn't present.
    let policies_dir = manifest_dir.join("../../docs/policies");
    let check_policies = policies_dir.is_dir();
    for rule in &rules {
        validate_status(&rule.id, &rule.status);
        validate_severity(&rule.id, &rule.normative_severity);
        validate_blocker(&rule.id, &rule.status, rule.blocker.as_deref());
        validate_coverage_kind(&rule.id, rule.coverage_kind.as_deref());
        if check_policies && rule.blocker.as_deref() == Some("Policy") {
            let adr_path = policies_dir.join(format!("{}.md", rule.id));
            if !adr_path.exists() {
                panic!(
                    "rule {}: blocker = \"Policy\" requires docs/policies/{}.md (not found at {})",
                    rule.id,
                    rule.id,
                    adr_path.display()
                );
            }
        }
    }

    write_rule_catalog(&out_dir.join("rule_catalog.rs"), &rules);
    write_spec_meta(&out_dir.join("rule_spec_meta.rs"), &catalog.meta);

    println!("cargo:rerun-if-changed=rules.toml");
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed=../../docs/policies");
}

const VALID_STATUSES: &[&str] = &[
    "Error",
    "Warning",
    "Configurable",
    "MachineUncheckable",
    "Unimplemented",
];

fn validate_status(rule_id: &str, status: &str) -> &'static str {
    for valid in VALID_STATUSES {
        if status == *valid {
            return valid;
        }
    }
    panic!("rule {rule_id}: invalid status `{status}` (expected one of {VALID_STATUSES:?})");
}

fn validate_severity(rule_id: &str, severity: &str) {
    match severity {
        "MUST" | "SHOULD" | "MAY" => {}
        other => panic!(
            "rule {rule_id}: invalid normative_severity `{other}` (expected MUST, SHOULD, or MAY)"
        ),
    }
}

fn validate_coverage_kind(rule_id: &str, coverage_kind: Option<&str>) {
    if let Some(value) = coverage_kind {
        match value {
            "OntologyKnownTermsOnly"
            | "LocalReferencesOnly"
            | "LexicalShapeOnly"
            | "PolicyDefaultUndecided" => {}
            other => panic!(
                "rule {rule_id}: invalid coverage_kind `{other}` (expected \
                 OntologyKnownTermsOnly, LocalReferencesOnly, LexicalShapeOnly, \
                 or PolicyDefaultUndecided)"
            ),
        }
    }
}

fn validate_blocker(rule_id: &str, status: &str, blocker: Option<&str>) {
    // `Error` and `Warning` are unconditional algorithms — no blocker.
    // Every other status carries a blocker that names the configuration
    // axis (Configurable), the spec context (MachineUncheckable), or
    // what's needed to implement (Unimplemented).
    let needs_blocker = !matches!(status, "Error" | "Warning");
    match (needs_blocker, blocker) {
        (true, None) => {
            panic!("rule {rule_id}: status `{status}` requires a `blocker = \"...\"` entry")
        }
        (false, Some(b)) => panic!(
            "rule {rule_id}: status `{status}` must not have a `blocker` entry (found `{b}`)"
        ),
        (true, Some(b)) => match b {
            "Ontology" | "Resolver" | "StrictDatatype" | "Policy" | "External" => {}
            other => panic!(
                "rule {rule_id}: invalid blocker `{other}` (expected Ontology, Resolver, \
                 StrictDatatype, Policy, or External)"
            ),
        },
        (false, None) => {}
    }
}

fn write_rule_catalog(path: &Path, rules: &[RawRule]) {
    use std::fmt::Write;
    let mut buf = String::new();
    buf.push_str(
        "// Generated by build.rs from rules.toml. Do not edit by hand.\n\
         const VALIDATION_RULE_STATUSES: &[ValidationRuleStatus] = &[\n",
    );
    for rule in rules {
        let status = validate_status(&rule.id, &rule.status);
        let severity = match rule.normative_severity.as_str() {
            "MUST" => "Must",
            "SHOULD" => "Should",
            "MAY" => "May",
            other => panic!("rule {}: invalid severity `{other}`", rule.id),
        };
        writeln!(buf, "    ValidationRuleStatus {{").unwrap();
        writeln!(buf, "        rule: {},", rust_string_literal(&rule.id)).unwrap();
        writeln!(buf, "        status: RuleStatus::{status},").unwrap();
        writeln!(
            buf,
            "        normative_severity: NormativeSeverity::{severity},"
        )
        .unwrap();
        writeln!(
            buf,
            "        spec_section: {},",
            rust_string_literal(&rule.spec_section)
        )
        .unwrap();
        writeln!(buf, "        note: {},", rust_string_literal(&rule.note)).unwrap();
        match rule.blocker.as_deref() {
            Some(b) => writeln!(buf, "        blocker: Some(super::Blocker::{b}),").unwrap(),
            None => writeln!(buf, "        blocker: None,").unwrap(),
        }
        match rule.validator_function.as_deref() {
            Some(fn_name) => writeln!(
                buf,
                "        validator_function: Some({}),",
                rust_string_literal(fn_name)
            )
            .unwrap(),
            None => writeln!(buf, "        validator_function: None,").unwrap(),
        }
        match rule.coverage_kind.as_deref() {
            Some(kind) => writeln!(
                buf,
                "        coverage_kind: Some(super::CoverageKind::{kind}),"
            )
            .unwrap(),
            None => writeln!(buf, "        coverage_kind: None,").unwrap(),
        }
        writeln!(buf, "    }},").unwrap();
    }
    buf.push_str("];\n");
    fs::write(path, buf).unwrap_or_else(|err| panic!("write {}: {err}", path.display()));
}

fn write_spec_meta(path: &Path, meta: &Meta) {
    use std::fmt::Write;
    let mut buf = String::new();
    buf.push_str("// Generated by build.rs from rules.toml. Do not edit by hand.\n");
    writeln!(
        buf,
        "pub const VALIDATION_RULE_SPEC_VERSION: &str = {};",
        rust_string_literal(&meta.spec_version)
    )
    .unwrap();
    writeln!(
        buf,
        "pub const VALIDATION_RULE_SPEC_PATH: &str = {};",
        rust_string_literal(&meta.spec_path)
    )
    .unwrap();
    writeln!(
        buf,
        "pub const VALIDATION_RULE_SPEC_CANONICAL_URL: &str = {};",
        rust_string_literal(&meta.spec_canonical_url)
    )
    .unwrap();
    writeln!(
        buf,
        "pub const VALIDATION_RULE_SPEC_PDF_SHA256: &str = {};",
        rust_string_literal(&meta.spec_pdf_sha256)
    )
    .unwrap();
    fs::write(path, buf).unwrap_or_else(|err| panic!("write {}: {err}", path.display()));
}

fn rust_string_literal(value: &str) -> String {
    let mut out = String::with_capacity(value.len() + 2);
    out.push('"');
    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => {
                use std::fmt::Write;
                write!(out, "\\u{{{:x}}}", c as u32).unwrap();
            }
            c => out.push(c),
        }
    }
    out.push('"');
    out
}