sbol 0.2.0

Rust implementation of the SBOL 3.1.0 specification.
Documentation
//! Render `docs/conformance.md` from the validation rule catalog.

use crate::{
    Blocker, RuleStatus, VALIDATION_RULE_SPEC_PATH, VALIDATION_RULE_SPEC_VERSION,
    ValidationRuleStatus,
};

/// Renders the conformance report markdown for the supplied rule
/// catalog. Pass `validation_rule_statuses()` to render the canonical
/// committed report.
pub fn render_conformance_report(statuses: &[ValidationRuleStatus]) -> String {
    let mut out = String::new();
    render_header(&mut out);
    render_headline_coverage(&mut out, statuses);
    render_status_taxonomy(&mut out, statuses);
    render_machine_checkable_gaps(&mut out, statuses);
    render_runtime_coverage_signal(&mut out);
    render_per_status_section(
        &mut out,
        statuses,
        RuleStatus::Error,
        "Error",
        "Algorithm complete; MUST violations emit as `Severity::Error`.",
    );
    render_per_status_section(
        &mut out,
        statuses,
        RuleStatus::Warning,
        "Warning",
        "Algorithm complete; SHOULD violations emit as `Severity::Warning`.",
    );
    render_configurable_section(&mut out, statuses);
    render_machine_uncheckable_section(&mut out, statuses);
    render_per_status_section(
        &mut out,
        statuses,
        RuleStatus::Unimplemented,
        "Unimplemented",
        "No local algorithm yet. The `Blocker` column names what's needed.",
    );
    render_profile_section(&mut out);
    out
}

fn render_header(out: &mut String) {
    out.push_str("# SBOL 3.1.0 Conformance Report\n\n");
    out.push_str(
        "This file is generated by `cargo run -p sbol --bin generate-conformance-report`.\n",
    );
    out.push_str(
        "It is committed and CI runs `git diff --exit-code docs/conformance.md` to enforce\n",
    );
    out.push_str("freshness after every change to `validation_rule_statuses()`.\n\n");
    out.push_str(&format!(
        "Source spec: `{VALIDATION_RULE_SPEC_PATH}` (version {VALIDATION_RULE_SPEC_VERSION}).\n\n"
    ));
}

fn render_headline_coverage(out: &mut String, statuses: &[ValidationRuleStatus]) {
    let total = statuses.len();
    let triangle = statuses
        .iter()
        .filter(|s| s.is_machine_uncheckable())
        .count();
    let machine_checkable = total - triangle;
    let implemented = statuses
        .iter()
        .filter(|s| s.is_implemented() && s.is_machine_checkable())
        .count();
    let pct = (implemented as f64 / machine_checkable as f64) * 100.0;
    let unimplemented = machine_checkable - implemented;

    out.push_str("## Headline coverage\n\n");
    out.push_str(&format!(
        "**{implemented} of {machine_checkable} machine-checkable spec rules are fully \
         implemented ({pct:.1}%).**\n\n"
    ));
    out.push_str(
        "The catalog tracks all 149 SBOL 3.1.0 validation rules. Appendix B (p.2837–2840) \
         marks 40 of them with the \u{25B2} symbol: weak-REQUIRED conditions whose violations \
         are NOT to be machine-reported. Those are tracked separately from the headline \
         percentage: the runtime signals them as `RuleCoverage::not_applied { MachineUncheckable }`, \
         and the validator emits at most warnings (never errors) when its local subset can \
         decide a sub-case.\n\n",
    );
    out.push_str(&format!(
        "| | Count |\n| --- | --- |\n\
         | Total rules in spec | {total} |\n\
         | \u{25B2} machine-uncheckable per Appendix B | {triangle} |\n\
         | Machine-checkable | {machine_checkable} |\n\
         |   \u{2192} fully implemented | **{implemented}** |\n\
         |   \u{2192} not yet implemented | **{unimplemented}** |\n\n"
    ));
}

fn render_status_taxonomy(out: &mut String, statuses: &[ValidationRuleStatus]) {
    out.push_str("## Status taxonomy\n\n");
    out.push_str(
        "Every rule classifies into exactly one of five statuses. The `Blocker` column carries \
         the secondary axis where it applies.\n\n",
    );
    out.push_str("| Status | Count | Algorithm | Diagnostics | Blocker meaning |\n");
    out.push_str("| --- | --- | --- | --- | --- |\n");
    for (status, algorithm, diagnostics, blocker_meaning) in [
        (RuleStatus::Error, "Complete", "Errors", "(none)"),
        (RuleStatus::Warning, "Complete", "Warnings", "(none)"),
        (
            RuleStatus::Configurable,
            "Complete; scope or severity varies by config",
            "Errors / Warnings per config",
            "Which configuration axis: Resolver, Ontology snapshot, Policy, or External",
        ),
        (
            RuleStatus::MachineUncheckable,
            "May have local subset",
            "Warnings only, on positively-decidable cases",
            "Why the spec marks it \u{25B2}: Ontology, Policy, or External",
        ),
        (
            RuleStatus::Unimplemented,
            "None",
            "Never emits",
            "What's needed: Ontology data, Resolver protocol, Policy decision",
        ),
    ] {
        let count = statuses.iter().filter(|s| s.status == status).count();
        out.push_str(&format!(
            "| {} | {count} | {algorithm} | {diagnostics} | {blocker_meaning} |\n",
            status_label(status),
        ));
    }
    out.push_str(&format!("| **Total** | **{}** | | | |\n\n", statuses.len()));
}

fn render_machine_checkable_gaps(out: &mut String, statuses: &[ValidationRuleStatus]) {
    let gaps: Vec<&ValidationRuleStatus> = statuses
        .iter()
        .filter(|s| s.is_machine_checkable() && !s.is_implemented())
        .collect();
    out.push_str("## Machine-checkable gaps\n\n");
    if gaps.is_empty() {
        out.push_str("None. Every machine-checkable rule is fully implemented.\n\n");
        return;
    }
    out.push_str(&format!(
        "{} machine-checkable rules are `Unimplemented`. Each one's blocker names what's \
         needed.\n\n",
        gaps.len()
    ));
    out.push_str("| Rule | Blocker | Severity | Why it's a gap |\n");
    out.push_str("| --- | --- | --- | --- |\n");
    for entry in gaps {
        let blocker = entry.blocker.map(blocker_label).unwrap_or("(none)");
        let severity = match entry.normative_severity {
            crate::NormativeSeverity::Must => "MUST",
            crate::NormativeSeverity::Should => "SHOULD",
            crate::NormativeSeverity::May => "MAY",
        };
        let note = entry.note.replace('|', "\\|");
        out.push_str(&format!(
            "| `{}` | `{blocker}` | {severity} | {note} |\n",
            entry.rule
        ));
    }
    out.push('\n');
}

fn render_configurable_section(out: &mut String, statuses: &[ValidationRuleStatus]) {
    let entries: Vec<&ValidationRuleStatus> = statuses
        .iter()
        .filter(|s| s.status == RuleStatus::Configurable)
        .collect();
    if entries.is_empty() {
        return;
    }
    out.push_str("## Configurable\n\n");
    out.push_str(&format!(
        "**{} rules.** Behavior depends on configuration. The `Blocker` column names the axis:\n\n\
         - `Resolver`: algorithm needs `--external-mode provided|allowed` for full scope. \
         Without one, coverage records `LocalReferencesOnly`.\n\
         - `Ontology`: algorithm correct for every snapshot-known term; out-of-snapshot terms \
         left undecided by design.\n\
         - `Policy`: algorithm runs at the `Conservative` `PolicyOptions` default; `Strict` and \
         `Lenient` modes alter emit behavior. See `docs/policies/<id>.md`.\n\
         - `External`: local-only mode is the implementation. The spec scope is structurally \
         unreachable without external infrastructure (a global registry); the validator does \
         what it can locally and documents the boundary.\n\n",
        entries.len()
    ));

    out.push_str("### By configuration axis\n\n");
    out.push_str("| Axis | Count |\n| --- | --- |\n");
    for blocker in [
        Blocker::Resolver,
        Blocker::Ontology,
        Blocker::Policy,
        Blocker::External,
    ] {
        let count = entries
            .iter()
            .filter(|e| e.blocker == Some(blocker))
            .count();
        if count > 0 {
            out.push_str(&format!("| `{}` | {count} |\n", blocker_label(blocker)));
        }
    }
    out.push('\n');

    out.push_str("| Rule | Axis | Note |\n");
    out.push_str("| --- | --- | --- |\n");
    for entry in entries {
        let safe_note = entry.note.replace('|', "\\|");
        let blocker = entry.blocker.map(blocker_label).unwrap_or("(unspecified)");
        out.push_str(&format!(
            "| `{}` | `{blocker}` | {safe_note} |\n",
            entry.rule
        ));
    }
    out.push('\n');
}

fn render_machine_uncheckable_section(out: &mut String, statuses: &[ValidationRuleStatus]) {
    let entries: Vec<&ValidationRuleStatus> = statuses
        .iter()
        .filter(|s| s.is_machine_uncheckable())
        .collect();
    if entries.is_empty() {
        return;
    }
    out.push_str("## \u{25B2} MachineUncheckable\n\n");
    out.push_str(&format!(
        "**{} rules.** Appendix B \u{25B2}: violations are NOT to be machine-reported. The \
         validator runs a local subset for some of these and emits warnings on \
         positively-decidable cases; the broader spec rule is always recorded in \
         `RuleCoverage::not_applied {{ MachineUncheckable }}`.\n\n",
        entries.len()
    ));

    let with_validator = entries
        .iter()
        .filter(|e| e.validator_function.is_some())
        .count();
    let without = entries.len() - with_validator;
    out.push_str(&format!(
        "| Local subset? | Count |\n| --- | --- |\n\
         | Yes, emits warnings on positively-decidable cases | {with_validator} |\n\
         | No, coverage signal only | {without} |\n\n"
    ));

    out.push_str("| Rule | Local subset? | Blocker | Note |\n");
    out.push_str("| --- | --- | --- | --- |\n");
    for entry in entries {
        let safe_note = entry.note.replace('|', "\\|");
        let blocker = entry.blocker.map(blocker_label).unwrap_or("(none)");
        let subset = if entry.validator_function.is_some() {
            "Yes"
        } else {
            "No"
        };
        out.push_str(&format!(
            "| `{}` | {subset} | `{blocker}` | {safe_note} |\n",
            entry.rule
        ));
    }
    out.push('\n');
}

fn render_runtime_coverage_signal(out: &mut String) {
    out.push_str("## Runtime coverage signal\n\n");
    out.push_str("Every `ValidationReport` carries a `RuleCoverage` value with three buckets:\n\n");
    out.push_str(
        "- `fully_applied`: every spec case for this rule is decidable with the current \
         configuration and was evaluated against this document.\n",
    );
    out.push_str(
        "- `partially_applied`: the local subset ran but the rule's full coverage is \
         configuration- or snapshot-bounded. Each entry carries a `coverage_kind`: \
         `OntologyKnownTermsOnly`, `LocalReferencesOnly`, `LexicalShapeOnly`, or \
         `PolicyDefaultUndecided`.\n",
    );
    out.push_str(
        "- `not_applied`: either the rule is `Unimplemented` (no local algorithm; \
         `reason: Deferred(blocker)`) or it is a \u{25B2} rule whose violations the spec \
         asks tools not to report (`reason: MachineUncheckable`).\n\n",
    );
    out.push_str(
        "Callers wanting a strict CI gate use `Document::check_complete` (or the CLI's \
         `--treat-partial-as-errors`) which returns an error whenever `partially_applied` is \
         non-empty. The default `Document::check` returns errors only on hard diagnostics, \
         leaving coverage gaps observable but non-fatal.\n\n",
    );
}

fn render_per_status_section(
    out: &mut String,
    statuses: &[ValidationRuleStatus],
    status: RuleStatus,
    title: &str,
    description: &str,
) {
    let entries: Vec<&ValidationRuleStatus> =
        statuses.iter().filter(|s| s.status == status).collect();
    if entries.is_empty() {
        return;
    }
    let count = entries.len();
    out.push_str(&format!("## {title}\n\n"));
    out.push_str(&format!(
        "**{count} rule{}.** {description}\n\n",
        plural(count)
    ));
    let needs_blocker_column = entries.iter().any(|e| e.blocker.is_some());
    if needs_blocker_column {
        out.push_str("| Rule | Blocker | Note |\n");
        out.push_str("| --- | --- | --- |\n");
        for entry in entries {
            let safe_note = entry.note.replace('|', "\\|");
            let blocker = entry.blocker.map(blocker_label).unwrap_or("");
            let blocker_cell = if blocker.is_empty() {
                String::new()
            } else {
                format!("`{blocker}`")
            };
            out.push_str(&format!(
                "| `{}` | {blocker_cell} | {safe_note} |\n",
                entry.rule
            ));
        }
    } else {
        out.push_str("| Rule | Note |\n");
        out.push_str("| --- | --- |\n");
        for entry in entries {
            let safe_note = entry.note.replace('|', "\\|");
            out.push_str(&format!("| `{}` | {safe_note} |\n", entry.rule));
        }
    }
    out.push('\n');
}

fn status_label(status: RuleStatus) -> &'static str {
    match status {
        RuleStatus::Error => "`Error`",
        RuleStatus::Warning => "`Warning`",
        RuleStatus::Configurable => "`Configurable`",
        RuleStatus::MachineUncheckable => "`MachineUncheckable`",
        RuleStatus::Unimplemented => "`Unimplemented`",
    }
}

fn blocker_label(blocker: Blocker) -> &'static str {
    match blocker {
        Blocker::Ontology => "Ontology",
        Blocker::Resolver => "Resolver",
        Blocker::StrictDatatype => "StrictDatatype",
        Blocker::Policy => "Policy",
        Blocker::External => "External",
    }
}

fn plural(n: usize) -> &'static str {
    if n == 1 { "" } else { "s" }
}

fn render_profile_section(out: &mut String) {
    out.push_str("## Compliance profile claims\n\n");
    out.push_str("These claims are provisional and describe what is currently exercised by the test suite. Release-grade profile boundaries will be finalized before the first crates.io publish.\n\n");
    out.push_str("- **Structural import / export**: Turtle parse and serialize for the SBOLTestSuite fixture corpus is exercised by `representative_fixtures_round_trip_as_normalized_rdf` in `crates/sbol/tests/sbol3_fixtures.rs`.\n");
    out.push_str("- **Lossless RDF round-trip**: Parse → write → reparse yields equal normalized triple sets for every representative fixture.\n");
    out.push_str("- **Typed round-trip with extension preservation**: `typed_round_trip_preserves_extension_triples_on_typed_objects` verifies that non-SBOL/PROV/OM predicates survive a typed rebuild.\n");
    out.push_str("- **Cross-implementation RDF I/O equivalence vs libSBOLj3**: 33 SBOLTestSuite SBOL3 fixtures (the full valid-input set in `tests/sbol3_fixtures_manifest.tsv`) parse and serialize to normalized triple sets identical to libSBOLj3 1.0.5.2 reference output. CI runs `cargo test --test cross_impl` against committed `*.libSBOLj3.expected.ttl` files in `tests/fixtures/cross-impl/`. This compares parser/serializer output only; the two libraries' validator outputs have not been systematically compared.\n");
    out.push_str("- **Property-based testing**: `crates/sbol/tests/properties.rs` exercises eight spec-derived properties under `proptest` at the default case count, including catalog coverage partitioning and override-builder semantics.\n");
    out.push_str("- **Fuzz coverage**: `cargo fuzz run read_turtle`, `cargo fuzz run round_trip`, `cargo fuzz run validate`, and `cargo fuzz run round_trip_validate` (see `fuzz/`) exercise the parser and validator under libFuzzer. CI smoke runs the parser targets for 60s each; the validator targets are local-only until corpus and budget are tuned.\n\n");
    out.push_str("## Per-rule regression cases\n\n");
    out.push_str("Every rule with an algorithm has at least one regression case in `crates/sbol/tests/rule_cases/`, organized by spec section. The catalog is enforced by `implemented_validation_rules_have_regression_cases` and `validation_rule_regression_cases_report_expected_rule_ids` in `crates/sbol/tests/validation_rules.rs`.\n\n");
    out.push_str("Selected high-value clusters also have positive cases (the rule must NOT fire on a valid example), verified by `positive_rule_cases_do_not_report_their_rule`.\n");
}