From f4d630663c5c746298c4e3366633cdb8fa0e3078 Mon Sep 17 00:00:00 2001 From: Brian H Date: Sun, 20 Sep 2026 08:42:27 +0000 Subject: [PATCH] feat(dialect,reqif): versioned wire contracts + ReqIF adapter migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds dialect::{DialectUrn, Upgrade, DialectError} — the forward-only, minor/patch-compatible decode contract for versioned wire artifacts, kept off the canonical typed layer per DESIGN-NOTE-typed-model-layer.md §2.8 (no SchemaVersion field on the type itself; version metadata lives on the decoding envelope instead). Requirement now implements Stereotyped (UfoStereotype::Kind, UFO-A rigid/sortal Endurant), gaining MbseExport for free. Migrates ReqIF parsing and the ReqIfBundle -> RequirementGraph adapter here from kr0ki-core::reqif_adapter, behind a new `reqif` feature (non-default, matching this crate's existing posture for young/small- bus-factor optional dependencies — sysml-v2-parser, scxml). The Rust orphan rule is what forces this: a downstream crate can't impl a foreign trait for a foreign type, so the ReqIF bridge has to live where RequirementGraph does. Adds reqif::parse_and_lower as the combined bytes -> RequirementGraph entry point so a caller with only bytes depends on zero reqrs API surface directly. See kr0ki's docs/DESIGN-NOTE-versioned-dialects-and-b00t-loader.md §1-3. Co-Authored-By: Claude Sonnet 5 --- Cargo.toml | 12 + src/dialect.rs | 117 ++++++++ src/lib.rs | 3 + src/mbse/requirements.rs | 14 + src/reqif.rs | 617 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 763 insertions(+) create mode 100644 src/dialect.rs create mode 100644 src/reqif.rs diff --git a/Cargo.toml b/Cargo.toml index 6363d01..a87833a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } thiserror = "2" +# Required by dialect::Upgrade / DialectUrn (versioned wire-contract +# decoding, forward-only minor/patch compat) — small, stable, always-on. +semver = "1" anyhow = "1" schemars = { version = "0.8", features = ["derive"] } rpassword = "7" @@ -47,12 +50,21 @@ pyo3 = { version = "0.27", features = ["extension-module"], optional = true } # renderers. Pinned to an exact version: small bus factor (2 contributors, # 0.x semver) per this epic's own crate-evaluation research. scxml = { version = "=0.2.2", optional = true } +# ReqIF/ReqIFz parsing (kr0ki M2 -> ufo-types migration, 2026-09-20) — a +# direct Rust port of the StrictDoc `reqif` reference implementation. +# Optional, matching `sysml-v2-parser`/`scxml`'s existing posture: `reqrs` +# is young (created 2026-05-28, pre-1.0, single apparent maintainer) and +# this crate does not put young/small-bus-factor dependencies in its +# default feature set. Consumers that want ReqIF ingestion opt in with +# `features = ["reqif"]`; kr0ki-core does. +reqrs = { version = "0.2.2", optional = true } [features] sysml = ["dep:sysml-v2-parser"] python = ["dep:pyo3", "sysml"] statechart = ["dep:scxml"] keyring = ["dep:keyring"] +reqif = ["dep:reqrs"] [dev-dependencies] serde_json = "1.0" diff --git a/src/dialect.rs b/src/dialect.rs new file mode 100644 index 0000000..d44c95b --- /dev/null +++ b/src/dialect.rs @@ -0,0 +1,117 @@ +//! Versioned wire-contract identity and forward-only decode. +//! +//! Sits *beside* the canonical typed layer, never inside it: per +//! `kr0ki`'s `docs/DESIGN-NOTE-typed-model-layer.md` §2.8, a canonical type +//! (`sysgraph::SysGraph`, `mbse::requirements::RequirementGraph`) carries no +//! `{major, minor}` field of its own — "stability is enforced by golden +//! fixtures + sha256 content hashes + frozen wire constants... a version +//! field invites the drift the fixture discipline exists to prevent." +//! [`DialectUrn`] and [`Upgrade`] name and decode a *wire artifact* that +//! produces one of those types; they never become a field on the type +//! itself. +//! +//! Distinct from an *adapter* (e.g. `reqif`'s ReqIF-XML -> `RequirementGraph` +//! lowering): an adapter crosses from a foreign format this crate does not +//! own the evolution of into a canonical type, once, with no version +//! negotiation. [`Upgrade`] is for decoding the *same* dialect — a format +//! this crate owns both ends of — at a historical minor/patch version +//! forward to the current shape. + +use std::fmt; + +/// A stable identity for one versioned wire dialect of a canonical type. +/// +/// Renders as `urn:b00t:dialect:::`, e.g. +/// `urn:b00t:dialect:ufo-types:mbse::requirements:0.15.0` — unifying the two +/// ad hoc conventions already in use in kr0ki-core before this module +/// existed: `b00t.type/reqif-import/v1` (envelope identity, major-only) and +/// `ufo-types/0.15.0:mbse::requirements` (crate + full semver + path). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DialectUrn { + pub crate_name: &'static str, + pub path: &'static str, + pub version: semver::Version, +} + +impl fmt::Display for DialectUrn { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "urn:b00t:dialect:{}:{}:{}", + self.crate_name, self.path, self.version + ) + } +} + +/// Why an [`Upgrade::upgrade`] call failed. +#[derive(Debug, thiserror::Error)] +pub enum DialectError { + /// `from_version`'s major component does not match the target type's + /// `Upgrade::DIALECT` major. A major bump is a breaking wire change — + /// `upgrade()` never attempts it; the caller needs an explicit, + /// separately-versioned migration instead. + #[error( + "dialect major-version mismatch: bytes declare {from_version} but {dialect} only upgrades within major {expected_major}" + )] + MajorMismatch { + from_version: semver::Version, + // Boxed: `DialectUrn` embeds a `semver::Version` too, and clippy's + // `result_large_err` flags `Result` (this is + // `Upgrade::upgrade`'s return type) as too large to return by value + // otherwise. + dialect: Box, + expected_major: u64, + }, + /// The bytes did not decode as this dialect at the declared version. + #[error("malformed {dialect} payload at declared version {from_version}: {reason}")] + Malformed { + dialect: Box, + from_version: semver::Version, + reason: String, + }, +} + +/// Forward-only decode: turn versioned wire bytes into the current +/// in-process value. +/// +/// There is deliberately no `downgrade()` on this trait. Concurrency is +/// structural rather than incidental: `upgrade()` is a plain function per +/// call with no shared mutable dispatch state, so many callers can decode +/// many sources at many declared versions at once with zero contention — +/// e.g. a `b00t://` loader iterating several concurrently-registered +/// sources, each at its own historical minor/patch version of the same +/// dialect. +pub trait Upgrade: Sized { + /// This type's current dialect identity. Bump the version here as this + /// type's wire contract evolves within the same major; existing + /// `from_version` handling in `upgrade()` accrues, it never drops + /// support for a version this type has shipped at the current major. + const DIALECT: DialectUrn; + + /// Decode `bytes`, produced against `from_version` of this same + /// dialect. Returns [`DialectError::MajorMismatch`] without attempting + /// a decode when the majors differ. Every `from_version` this type has + /// ever shipped at the current major MUST succeed — enforced by a + /// golden-fixture round-trip test per historical version, the same bar + /// `kr0ki-core`'s `normalized_graph_sha256` already sets for a single + /// version, applied per version instead of once. + fn upgrade(bytes: &[u8], from_version: &semver::Version) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dialect_urn_renders_as_urn_b00t_dialect() { + let urn = DialectUrn { + crate_name: "ufo-types", + path: "mbse::requirements", + version: semver::Version::new(0, 15, 0), + }; + assert_eq!( + urn.to_string(), + "urn:b00t:dialect:ufo-types:mbse::requirements:0.15.0" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 67559cd..1a2a8ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -158,6 +158,7 @@ pub mod capability; pub mod coherence; pub mod dare; pub mod data_format; +pub mod dialect; pub mod iso; pub mod iso_ir; pub mod mbse; @@ -169,6 +170,8 @@ pub mod pipeline_secrets; pub mod pipeline_types; #[cfg(feature = "python")] mod python; +#[cfg(feature = "reqif")] +pub mod reqif; pub mod satisfies; #[cfg(feature = "statechart")] pub mod statechart; diff --git a/src/mbse/requirements.rs b/src/mbse/requirements.rs index 94febac..44c9a18 100644 --- a/src/mbse/requirements.rs +++ b/src/mbse/requirements.rs @@ -12,6 +12,8 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use serde::{Deserialize, Serialize}; +use crate::stereotype::{Stereotyped, UfoStereotype}; + /// An immutable ReqIF/Flexo baseline identity. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BaselineIdentity { @@ -63,6 +65,18 @@ pub struct Requirement { pub evidence: Vec, } +/// A `Requirement` is UFO-A rigid and sortal: an entity cannot lose its +/// identity and remain a requirement (contrast a `Role`, which can be +/// gained or lost without changing identity — see `stereotype.rs`'s +/// classification table). `Kind`, not `SubKind`: this is the requirement +/// vocabulary's own root type, not a further specialization of some other +/// `Kind` already in the ontology. +impl Stereotyped for Requirement { + fn ufo_stereotype(&self) -> UfoStereotype { + UfoStereotype::Kind("Requirement".to_string()) + } +} + /// The asserted relation vocabulary shared by ReqIF, Flexo, and OPA adapters. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/src/reqif.rs b/src/reqif.rs new file mode 100644 index 0000000..76b71b0 --- /dev/null +++ b/src/reqif.rs @@ -0,0 +1,617 @@ +//! ReqIF -> [`crate::mbse::requirements::RequirementGraph`] adapter. +//! +//! Migrated here 2026-09-20 from `kr0ki-core::reqif_adapter` (kr0ki PR +//! #40/#41, originally landed 2026-09-20) per +//! `kr0ki`'s `docs/DESIGN-NOTE-versioned-dialects-and-b00t-loader.md` §3: the +//! Rust orphan rule means a downstream crate cannot `impl Upgrade for +//! RequirementGraph` itself (both the trait and the type are foreign to +//! it), so the bridge has to live where `RequirementGraph` does. Gated +//! behind the `reqif` feature (on by default only in a consumer's own +//! Cargo.toml, not required here) rather than an unconditional dependency: +//! [`reqrs`] is young (created 2026-05-28, pre-1.0, single apparent +//! maintainer) and this crate does not put dependencies of that maturity in +//! its own default feature set — see `Cargo.toml`'s `reqrs` comment. If +//! `reqrs` is replaced later, this is the one module that changes; nothing +//! else in the ecosystem depending on `ufo-types` notices. +//! +//! Parsing itself is [`reqrs`]'s job (decided 2026-09-20, kr0ki +//! `docs/HANDOFF-2026-09-19-reqif-flexo.md`'s "Deliberate boundaries" and +//! [kr0ki#39](https://github.com/PromptExecution/kr0ki/issues/39)): a direct +//! Rust dependency, not a sidecar call to the vendored `reqif-opa-mcp` +//! submodule. This module's job is narrowed to one thing — lower a parsed +//! [`reqrs::model::ReqIfBundle`] onto +//! [`crate::mbse::requirements::RequirementGraph`] — mirroring the mapping +//! already written once in Python for `reqif-opa-mcp` +//! (`reqif_mcp/reqif_parser.py` + `reqif_mcp/normalization.py`, +//! `PromptExecution/reqif-opa-mcp#25`), except the target here is the +//! shared semantic contract, not a bespoke OPA-input schema. +//! +//! Attribute values are keyed by their `` +//! `LONG-NAME`, lowercased and snake-cased (`attribute_definition_names`), +//! the same convention `normalization.py::_normalize_spec_object` uses to +//! build its `attrs_map`. A requirement's `title`/`text` are then derived +//! from that map (falling back through `name`/`title`/`key` for `title`, +//! `text`/`description` for `text`) rather than from any ReqIF-standard +//! field, because ReqIF itself has no dedicated title/text elements — +//! everything beyond `IDENTIFIER`/`LONG-NAME` lives in vendor-defined +//! attributes. +//! +//! This is an *adapter* (a one-way lowering from a foreign format this +//! crate does not own the evolution of), not a [`crate::dialect::Upgrade`] +//! impl: ReqIF has its own, externally-owned version axis, unrelated to +//! `ufo-types`' semver. See `crate::dialect`'s module doc for the +//! distinction. + +use std::collections::BTreeMap; + +use reqrs::ids::{AttributeDefId, EnumValueId, SpecTypeId}; +use reqrs::model::{ + AttributeDefinition, AttributeValue, DataType, ReqIfBundle, SpecObject, SpecRelation, SpecType, +}; + +use crate::mbse::requirements::{ + BaselineIdentity, Provenance, RelationAuthority, Requirement, RequirementError, + RequirementGraph, RequirementRelation, RequirementRelationKind, +}; + +/// Caller-supplied identity for the baseline a bundle represents. ReqIF has +/// no field that maps onto [`BaselineIdentity::revision`] (an "immutable +/// source revision" is a Flexo/kr0ki concept, not a ReqIF one) — the caller +/// supplies one, e.g. a git commit, an import timestamp, or a content hash +/// of the raw bytes. +#[derive(Debug, Clone)] +pub struct ReqIfAdapterConfig { + /// Opaque locator for the source artifact (file path, URI, etc.) — + /// becomes both [`Provenance::source_uri`] and, when the bundle's own + /// `` is absent, the baseline id. + pub source_uri: String, + pub revision: String, + pub import_artifact_sha256: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ReqIfAdapterError { + #[error(" element not found")] + MissingContent, + #[error(transparent)] + Graph(#[from] RequirementError), + #[error(transparent)] + Parse(#[from] reqrs::ReqIfError), +} + +/// Parse raw ReqIF XML bytes and lower the result onto a [`RequirementGraph`] +/// in one call — the combined entry point a caller with only bytes (e.g. +/// `kr0ki-core`'s HTTP/MCP intake boundary) uses instead of calling +/// [`reqrs::ReqIfParser`] and [`bundle_to_requirement_graph`] separately. +/// Keeping this here, not in the caller, is what lets a `reqif`-feature +/// consumer depend on zero `reqrs` API surface directly. +pub fn parse_and_lower( + bytes: &[u8], + config: &ReqIfAdapterConfig, +) -> Result { + let bundle = reqrs::ReqIfParser::parse_bytes(bytes)?; + bundle_to_requirement_graph(&bundle, config) +} + +/// Map a parsed [`ReqIfBundle`] onto a [`RequirementGraph`], validating the +/// result before returning it (see [`RequirementGraph::validate`]). +pub fn bundle_to_requirement_graph( + bundle: &ReqIfBundle, + config: &ReqIfAdapterConfig, +) -> Result { + let content = bundle + .core_content + .as_ref() + .and_then(|core| core.req_if_content.as_ref()) + .ok_or(ReqIfAdapterError::MissingContent)?; + + let baseline = BaselineIdentity { + id: bundle + .header + .as_ref() + .map(|header| header.identifier.clone()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| config.source_uri.clone()), + revision: config.revision.clone(), + import_artifact_sha256: config.import_artifact_sha256.clone(), + exported_baseline_sha256: None, + }; + let provenance = Provenance { + source_uri: config.source_uri.clone(), + artifact_sha256: config.import_artifact_sha256.clone(), + locator: None, + }; + + let spec_types = content.spec_types.as_deref().unwrap_or(&[]); + let attr_def_names = attribute_definition_names(spec_types); + let spec_type_names = spec_type_names(spec_types); + let enum_value_names = enum_value_names(content.data_types.as_deref().unwrap_or(&[])); + + let requirements = content + .spec_objects + .as_deref() + .unwrap_or(&[]) + .iter() + .map(|spec_object| { + spec_object_to_requirement( + spec_object, + &attr_def_names, + &spec_type_names, + &enum_value_names, + &baseline, + &provenance, + ) + }) + .collect(); + + let relations = content + .spec_relations + .as_deref() + .unwrap_or(&[]) + .iter() + .map(|spec_relation| { + spec_relation_to_requirement_relation(spec_relation, &spec_type_names, &provenance) + }) + .collect(); + + let graph = RequirementGraph { + baseline, + requirements, + evidence: Vec::new(), + relations, + }; + graph.validate()?; + Ok(graph) +} + +fn attribute_definition_common(def: &AttributeDefinition) -> &reqrs::model::AttributeDefCommon { + match def { + AttributeDefinition::String(d) => &d.common, + AttributeDefinition::Boolean(d) => &d.common, + AttributeDefinition::Integer(d) => &d.common, + AttributeDefinition::Real(d) => &d.common, + AttributeDefinition::Date(d) => &d.common, + AttributeDefinition::Xhtml(d) => &d.common, + AttributeDefinition::Enumeration(d) => &d.common, + } +} + +fn spec_type_common(spec_type: &SpecType) -> &reqrs::model::SpecTypeCommon { + match spec_type { + SpecType::SpecObject(t) => &t.common, + SpecType::Specification(t) => &t.common, + SpecType::SpecRelation(t) => &t.common, + SpecType::RelationGroup(t) => &t.common, + } +} + +/// `` id -> normalized `LONG-NAME`, flattened across +/// every `` entry. Mirrors `attr_defs_map` in +/// `reqif_mcp/normalization.py`. +fn attribute_definition_names(spec_types: &[SpecType]) -> BTreeMap { + spec_types + .iter() + .flat_map(|spec_type| spec_type_common(spec_type).spec_attributes.iter().flatten()) + .filter_map(|def| { + let long_name = attribute_definition_common(def).long_name.as_deref()?; + Some((def.identifier().clone(), normalize_key(long_name))) + }) + .collect() +} + +/// `` id -> normalized `LONG-NAME`. Used to recover a +/// requirement's subtype and a relation's [`RequirementRelationKind`] when +/// no more specific attribute carries that information. +fn spec_type_names(spec_types: &[SpecType]) -> BTreeMap { + spec_types + .iter() + .map(|spec_type| { + let common = spec_type_common(spec_type); + let name = common.long_name.as_deref().unwrap_or_default(); + (common.identifier.clone(), normalize_key(name)) + }) + .collect() +} + +/// `` id -> its human-readable value. `LONG-NAME` is the ReqIF +/// display value; `KEY` is a vendor-stable fallback when no long name exists. +fn enum_value_names(data_types: &[DataType]) -> BTreeMap { + data_types + .iter() + .filter_map(|data_type| match data_type { + DataType::Enumeration(data_type) => data_type.specified_values.as_deref(), + _ => None, + }) + .flatten() + .filter_map(|value| { + value + .long_name + .as_deref() + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .or_else(|| (!value.key.is_empty()).then(|| value.key.clone())) + .map(|name| (value.identifier.clone(), name)) + }) + .collect() +} + +fn normalize_key(s: &str) -> String { + s.to_lowercase().replace([' ', '-'], "_") +} + +fn attribute_value_string( + value: &AttributeValue, + enum_value_names: &BTreeMap, +) -> String { + match value { + AttributeValue::String(v) => v.value.clone(), + AttributeValue::Boolean(v) => v.value.to_string(), + AttributeValue::Integer(v) => v.value.clone(), + AttributeValue::Real(v) => v.value.clone(), + AttributeValue::Date(v) => v.value.clone(), + AttributeValue::Xhtml(v) => v.the_value_raw.clone(), + AttributeValue::Enumeration(v) => v + .values + .iter() + .map(|id| { + enum_value_names + .get(id) + .map(String::as_str) + .unwrap_or_else(|| id.as_str()) + }) + .collect::>() + .join(", "), + } +} + +fn spec_object_to_requirement( + spec_object: &SpecObject, + attr_def_names: &BTreeMap, + spec_type_names: &BTreeMap, + enum_value_names: &BTreeMap, + baseline: &BaselineIdentity, + provenance: &Provenance, +) -> Requirement { + let mut attributes = BTreeMap::new(); + for value in &spec_object.attributes { + if let Some(key) = attr_def_names.get(value.definition_ref()) { + attributes.insert(key.clone(), attribute_value_string(value, enum_value_names)); + } + } + + // ReqIF's required SPEC-OBJECT-TYPE-REF is the canonical subtype when a + // vendor did not provide a populated `subtypes` or `type` attribute. + if !["subtypes", "type"] + .into_iter() + .any(|key| attributes.get(key).is_some_and(|value| !value.is_empty())) + { + if let Some(name) = spec_type_names + .get(&spec_object.spec_object_type) + .filter(|name| !name.is_empty()) + { + attributes.insert("subtypes".to_string(), name.clone()); + } + } + + let title = spec_object + .long_name + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| first_non_empty_attribute(&attributes, &["name", "title", "key"])) + .unwrap_or_else(|| spec_object.identifier.as_str().to_string()); + + let text = first_non_empty_attribute(&attributes, &["text", "description"]).unwrap_or_default(); + + Requirement { + id: spec_object.identifier.as_str().to_string(), + title, + text, + baseline: baseline.clone(), + provenance: provenance.clone(), + attributes, + evidence: Vec::new(), + } +} + +fn first_non_empty_attribute( + attributes: &BTreeMap, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| attributes.get(*key)) + .find(|value| !value.is_empty()) + .cloned() +} + +fn spec_relation_to_requirement_relation( + spec_relation: &SpecRelation, + spec_type_names: &BTreeMap, + provenance: &Provenance, +) -> RequirementRelation { + let kind = spec_type_names + .get(&spec_relation.relation_type) + .and_then(|name| relation_kind_from_name(name)) + .unwrap_or(RequirementRelationKind::Traces); + + RequirementRelation { + id: spec_relation.identifier.as_str().to_string(), + source: spec_relation.source.as_str().to_string(), + target: spec_relation.target.as_str().to_string(), + kind, + authority: RelationAuthority::Asserted, + provenance: provenance.clone(), + promotion: None, + } +} + +/// Maps a normalized `` onto the shared +/// [`RequirementRelationKind`] vocabulary. Unrecognized names fall back to +/// [`RequirementRelationKind::Traces`] in the caller, the same "don't lose +/// the edge, don't overclaim its meaning" default +/// `normalization.py::_extract_subtypes` uses for an unknown requirement +/// subtype (`GENERAL`). +fn relation_kind_from_name(name: &str) -> Option { + use RequirementRelationKind::*; + Some(match name { + "contains" => Contains, + "derives" | "derive" | "derived_from" => Derives, + "refines" => Refines, + "requires" => Requires, + "satisfies" => Satisfies, + "verifies" => Verifies, + "implements" => Implements, + "traces" => Traces, + "allocated_to" | "allocatedto" => AllocatedTo, + "precedes" => Precedes, + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use reqrs::ReqIfParser; + + /// Two spec objects (one carrying `Name`/`Text`/`Key` attributes, one + /// carrying only a `LONG-NAME`) plus one `derives` relation between + /// them. Exercises the full parse -> map path, not a hand-built bundle. + const FIXTURE: &str = r#" + + + + Adapter test baseline + + + + + + + + + + + + + + + + + + + DT-STRING + + + DT-STRING + + + DT-STRING + + + DT-STRING + + + DT-STRING + + + DT-STATUS + + + + + + + + ST-REQUIREMENT + + + AD-KEY + + + AD-TEXT + + + EV-ACTIVE + AD-STATUS + + + + + ST-REQUIREMENT + + + AD-NAME + + + AD-TITLE + + + AD-KEY + + + AD-TEXT + + + AD-DESCRIPTION + + + + + + + ST-DERIVES + REQ-2 + REQ-1 + + + + +"#; + + fn config() -> ReqIfAdapterConfig { + ReqIfAdapterConfig { + source_uri: "test://fixture.reqif".to_string(), + revision: "rev-1".to_string(), + import_artifact_sha256: Some("deadbeef".to_string()), + } + } + + #[test] + fn maps_baseline_from_header_identifier() { + let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); + let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + assert_eq!(graph.baseline.id, "BASELINE-TEST"); + assert_eq!(graph.baseline.revision, "rev-1"); + assert_eq!( + graph.baseline.import_artifact_sha256.as_deref(), + Some("deadbeef") + ); + } + + #[test] + fn maps_spec_object_long_name_and_attributes_to_title_and_text() { + let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); + let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + + let req1 = graph.requirements.iter().find(|r| r.id == "REQ-1").unwrap(); + assert_eq!(req1.title, "Encryption at rest"); + assert_eq!(req1.text, "System shall encrypt data at rest."); + assert_eq!( + req1.attributes.get("key").map(String::as_str), + Some("ENC-001") + ); + assert_eq!( + req1.attributes.get("status").map(String::as_str), + Some("Active") + ); + assert_eq!(req1.provenance.source_uri, "test://fixture.reqif"); + } + + #[test] + fn retains_object_type_as_subtype_when_vendor_attributes_are_absent() { + let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); + let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + + let req2 = graph.requirements.iter().find(|r| r.id == "REQ-2").unwrap(); + assert_eq!( + req2.attributes.get("subtypes").map(String::as_str), + Some("requirement") + ); + } + + #[test] + fn skips_empty_title_and_text_attributes_when_falling_back() { + let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); + let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + + let req2 = graph.requirements.iter().find(|r| r.id == "REQ-2").unwrap(); + assert_eq!(req2.title, "Derived encryption"); + assert_eq!(req2.text, "Derived encryption must remain protected."); + } + + #[test] + fn uses_enum_key_then_identifier_when_long_name_is_unavailable() { + let bundle = ReqIfParser::parse_str(&FIXTURE.replace(r#"LONG-NAME="Active""#, "")).unwrap(); + let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + assert_eq!( + graph.requirements[0] + .attributes + .get("status") + .map(String::as_str), + Some("active") + ); + + let bundle = ReqIfParser::parse_str(&FIXTURE.replace( + ">EV-ACTIVE", + ">EV-UNKNOWN", + )) + .unwrap(); + let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + assert_eq!( + graph.requirements[0] + .attributes + .get("status") + .map(String::as_str), + Some("EV-UNKNOWN") + ); + } + + #[test] + fn maps_spec_relation_to_requirement_relation_with_kind_from_relation_type() { + let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); + let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + + assert_eq!(graph.relations.len(), 1); + let rel = &graph.relations[0]; + assert_eq!(rel.id, "REL-1"); + assert_eq!(rel.source, "REQ-2"); + assert_eq!(rel.target, "REQ-1"); + assert_eq!(rel.kind, RequirementRelationKind::Derives); + assert!(rel.authority.is_asserted()); + } + + #[test] + fn returned_graph_passes_validation() { + let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); + let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + assert!(graph.validate().is_ok()); + } + + #[test] + fn missing_content_is_a_typed_error() { + let bundle = ReqIfParser::parse_str( + r#" + + +"#, + ) + .unwrap(); + let err = bundle_to_requirement_graph(&bundle, &config()).unwrap_err(); + assert!(matches!(err, ReqIfAdapterError::MissingContent)); + } + + #[test] + fn unrecognized_relation_type_name_falls_back_to_traces() { + let xml = FIXTURE.replace( + r#"LONG-NAME="Derives""#, + r#"LONG-NAME="Custom Vendor Link""#, + ); + let bundle = ReqIfParser::parse_str(&xml).unwrap(); + let graph = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + assert_eq!(graph.relations[0].kind, RequirementRelationKind::Traces); + } + + #[test] + fn parse_and_lower_matches_the_separate_parse_then_map_path() { + let bundle = ReqIfParser::parse_str(FIXTURE).unwrap(); + let expected = bundle_to_requirement_graph(&bundle, &config()).unwrap(); + + let actual = parse_and_lower(FIXTURE.as_bytes(), &config()).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn parse_and_lower_surfaces_a_parse_error_for_malformed_xml() { + let err = parse_and_lower(b"", &config()).unwrap_err(); + assert!(matches!(err, ReqIfAdapterError::Parse(_))); + } +}