use std::collections::{HashMap, HashSet};
use crate::sbol2_vocab as v2;
use crate::vocab as v3;
use crate::{Iri, Resource, Term, Triple};
use sbol_rdf::{Graph, ParseError, RdfFormat};
mod identity;
mod values;
use identity::IdentityMap;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct UpgradeOptions {
pub default_namespace: Option<Iri>,
pub preserve_backport: bool,
}
impl Default for UpgradeOptions {
fn default() -> Self {
Self {
default_namespace: None,
preserve_backport: true,
}
}
}
impl UpgradeOptions {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum UpgradeWarning {
NamespaceFallback {
subject: String,
source: NamespaceSource,
},
UnresolvedMapsTo { mapsto: String, side: MapsToSide },
UnsupportedRefinement { mapsto: String, refinement: String },
SequenceAnnotationWithComponent { annotation: String },
UnknownSbol2Type { subject: String, sbol2_type: String },
LocationWithoutSequence {
location: String,
component: String,
sequence_count: usize,
},
IdentityCollision {
canonical: String,
sources: Vec<String>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum NamespaceSource {
UrlOrigin,
DefaultOption,
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum MapsToSide {
Local,
Remote,
Carrier,
}
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct UpgradeCounts {
pub component_definitions: usize,
pub module_definitions: usize,
pub sub_components: usize,
pub sequence_features: usize,
pub sequence_annotations_collapsed: usize,
pub mapstos_decomposed: usize,
pub interfaces_synthesized: usize,
pub locations_with_inferred_sequence: usize,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct UpgradeReport {
warnings: Vec<UpgradeWarning>,
counts: UpgradeCounts,
}
impl UpgradeReport {
pub fn warnings(&self) -> &[UpgradeWarning] {
&self.warnings
}
pub fn counts(&self) -> &UpgradeCounts {
&self.counts
}
pub fn is_clean(&self) -> bool {
self.warnings.is_empty()
}
pub(crate) fn push(&mut self, warning: UpgradeWarning) {
self.warnings.push(warning);
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum UpgradeError {
Parse(ParseError),
NotSbol2,
}
impl std::fmt::Display for UpgradeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(err) => write!(f, "failed to parse input as RDF: {err}"),
Self::NotSbol2 => write!(
f,
"input contains no SBOL 2 typed subjects \
(http://sbols.org/v2# rdf:type) — nothing to upgrade",
),
}
}
}
impl std::error::Error for UpgradeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Parse(err) => Some(err),
Self::NotSbol2 => None,
}
}
}
impl From<ParseError> for UpgradeError {
fn from(err: ParseError) -> Self {
Self::Parse(err)
}
}
pub fn sbol2_to_sbol3(
graph: &Graph,
options: UpgradeOptions,
) -> Result<(Graph, UpgradeReport), UpgradeError> {
let mut engine = Engine::new(graph, options);
engine.preflight()?;
engine.convert();
Ok((Graph::new(engine.output_triples), engine.report))
}
pub fn parse_and_upgrade(
input: &str,
format: RdfFormat,
options: UpgradeOptions,
) -> Result<(Graph, UpgradeReport), UpgradeError> {
let graph = Graph::parse(input, format)?;
sbol2_to_sbol3(&graph, options)
}
#[derive(Clone, Copy)]
enum FcDirection {
Input,
Output,
Inout,
}
#[derive(Clone, Copy)]
enum RefinementShape {
UseLocal,
UseRemote,
VerifyIdentical,
}
#[derive(Default, Clone)]
struct MapsToInfo {
local: Option<String>,
remote: Option<String>,
refinement: Option<String>,
display_id: Option<String>,
}
struct Engine<'a> {
input: &'a Graph,
options: UpgradeOptions,
identity: IdentityMap,
typed_subjects: HashMap<String, String>,
cd_sequences: HashMap<String, Vec<String>>,
location_to_sa: HashMap<String, String>,
sa_to_cd: HashMap<String, String>,
sa_to_subcomponent: HashMap<String, String>,
display_ids: HashMap<String, String>,
owned_by: HashMap<String, String>,
mapsto_info: HashMap<String, MapsToInfo>,
fc_directions: HashMap<String, FcDirection>,
fc_direction_none: HashSet<String>,
fc_public_access: HashSet<String>,
output_triples: Vec<Triple>,
namespaced_subjects: HashSet<String>,
used_iris: HashSet<String>,
location_display_id_overrides: HashMap<String, String>,
report: UpgradeReport,
}
impl<'a> Engine<'a> {
fn new(input: &'a Graph, options: UpgradeOptions) -> Self {
let identity = IdentityMap::build(input);
Self {
input,
options,
identity,
typed_subjects: HashMap::new(),
cd_sequences: HashMap::new(),
location_to_sa: HashMap::new(),
sa_to_cd: HashMap::new(),
sa_to_subcomponent: HashMap::new(),
display_ids: HashMap::new(),
owned_by: HashMap::new(),
mapsto_info: HashMap::new(),
fc_directions: HashMap::new(),
fc_direction_none: HashSet::new(),
fc_public_access: HashSet::new(),
output_triples: Vec::new(),
namespaced_subjects: HashSet::new(),
used_iris: HashSet::new(),
location_display_id_overrides: HashMap::new(),
report: UpgradeReport::default(),
}
}
fn preflight(&mut self) -> Result<(), UpgradeError> {
let mut input_subjects: HashSet<String> = HashSet::new();
for triple in self.input.triples() {
if let Some(iri) = triple.subject.as_iri() {
input_subjects.insert(iri.as_str().to_owned());
}
}
let mut canonical_sources: HashMap<String, Vec<String>> = HashMap::new();
for subject in input_subjects {
let canonical = self.identity.rewrite_iri(&subject).to_owned();
self.used_iris.insert(canonical.clone());
canonical_sources
.entry(canonical)
.or_default()
.push(subject);
}
let mut collisions: Vec<(String, Vec<String>)> = canonical_sources
.into_iter()
.filter(|(_, sources)| sources.len() > 1)
.collect();
collisions.sort_by(|a, b| a.0.cmp(&b.0));
for (canonical, mut sources) in collisions {
sources.sort();
self.report
.push(UpgradeWarning::IdentityCollision { canonical, sources });
}
for triple in self.input.triples() {
let predicate = triple.predicate.as_str();
if predicate == v3::RDF_TYPE {
if let Some(iri) = triple.object.as_iri()
&& let Some(subject_iri) = triple.subject.as_iri()
{
let object = iri.as_str();
if object.starts_with(v2::SBOL2_NS)
|| object == v3::PROV_ACTIVITY
|| object == v3::PROV_AGENT_CLASS
|| object == v3::PROV_PLAN
{
let subject = subject_iri.as_str().to_owned();
let should_replace =
self.typed_subjects.get(&subject).is_none_or(|existing| {
type_precedence(object) > type_precedence(existing)
});
if should_replace {
self.typed_subjects.insert(subject, object.to_owned());
}
}
}
continue;
}
let subject = match triple.subject.as_iri() {
Some(iri) => iri.as_str().to_owned(),
None => continue,
};
if predicate == v2::SBOL2_DISPLAY_ID
&& let Some(lit) = triple.object.as_literal()
{
self.display_ids
.entry(subject.clone())
.or_insert_with(|| lit.value().to_owned());
}
let object = match triple.object.as_iri() {
Some(iri) => iri.as_str().to_owned(),
None => continue,
};
match predicate {
v2::SBOL2_SEQUENCE_PROP => {
self.cd_sequences
.entry(subject.clone())
.or_default()
.push(object.clone());
}
v2::SBOL2_SEQUENCE_ANNOTATION_PROP => {
self.sa_to_cd.insert(object.clone(), subject.clone());
self.owned_by.insert(object, subject);
}
v2::SBOL2_LOCATION_PROP => {
self.location_to_sa.insert(object.clone(), subject.clone());
self.owned_by.insert(object, subject);
}
v2::SBOL2_COMPONENT_PROP
| v2::SBOL2_FUNCTIONAL_COMPONENT_PROP
| v2::SBOL2_MODULE_PROP
| v2::SBOL2_INTERACTION_PROP
| v2::SBOL2_PARTICIPATION_PROP
| v2::SBOL2_MAPS_TO_PROP
| v2::SBOL2_VARIABLE_COMPONENT_PROP => {
self.owned_by.insert(object, subject);
}
_ => {}
}
}
if !self
.typed_subjects
.values()
.any(|t| t.starts_with(v2::SBOL2_NS))
{
return Err(UpgradeError::NotSbol2);
}
for triple in self.input.triples() {
if triple.predicate.as_str() != v2::SBOL2_COMPONENT_PROP {
continue;
}
let subject = match triple.subject.as_iri() {
Some(iri) => iri.as_str().to_owned(),
None => continue,
};
if self.typed_subjects.get(&subject).map(String::as_str)
!= Some(v2::SBOL2_SEQUENCE_ANNOTATION)
{
continue;
}
let target = match triple.object.as_iri() {
Some(iri) => iri.as_str().to_owned(),
None => continue,
};
self.sa_to_subcomponent.insert(subject, target);
}
let mut location_owners: Vec<(String, String)> = self
.location_to_sa
.iter()
.filter_map(|(loc, sa)| {
self.sa_to_subcomponent
.get(sa)
.map(|sc| (loc.clone(), sc.clone()))
})
.collect();
location_owners.sort();
for (loc_iri, subcomponent_iri) in location_owners {
let subcomponent_canonical = self.identity.rewrite_iri(&subcomponent_iri).to_owned();
let base_display_id = self.display_ids.get(&loc_iri).cloned().unwrap_or_else(|| {
last_path_segment(self.identity.rewrite_iri(&loc_iri)).to_owned()
});
let loc_canonical = self.identity.rewrite_iri(&loc_iri).to_owned();
self.used_iris.remove(&loc_canonical);
let (chosen_display_id, new_iri) = next_available_child_iri(
&subcomponent_canonical,
&base_display_id,
&mut self.used_iris,
);
if chosen_display_id != base_display_id {
self.location_display_id_overrides
.insert(loc_iri.clone(), chosen_display_id);
}
self.identity.add_rewrite(loc_iri, new_iri);
}
for triple in self.input.triples() {
if triple.predicate.as_str() != v2::SBOL2_DIRECTION {
continue;
}
let subject = match triple.subject.as_iri() {
Some(iri) => iri.as_str(),
None => continue,
};
if self.typed_subjects.get(subject).map(String::as_str)
!= Some(v2::SBOL2_FUNCTIONAL_COMPONENT)
{
continue;
}
let dir_iri = match triple.object.as_iri() {
Some(iri) => iri.as_str(),
None => continue,
};
let direction = match dir_iri {
v2::SBOL2_DIRECTION_IN => FcDirection::Input,
v2::SBOL2_DIRECTION_OUT => FcDirection::Output,
v2::SBOL2_DIRECTION_INOUT => FcDirection::Inout,
v2::SBOL2_DIRECTION_NONE => {
self.fc_direction_none.insert(subject.to_owned());
continue;
}
_ => continue,
};
self.fc_directions.insert(subject.to_owned(), direction);
}
for triple in self.input.triples() {
if triple.predicate.as_str() != v2::SBOL2_ACCESS {
continue;
}
let subject = match triple.subject.as_iri() {
Some(iri) => iri.as_str(),
None => continue,
};
if triple.object.as_iri().map(|i| i.as_str()) != Some(v2::SBOL2_ACCESS_PUBLIC) {
continue;
}
match self.typed_subjects.get(subject).map(String::as_str) {
Some(v2::SBOL2_FUNCTIONAL_COMPONENT) => {
self.fc_public_access.insert(subject.to_owned());
}
Some(v2::SBOL2_COMPONENT) => {
self.fc_directions
.entry(subject.to_owned())
.or_insert(FcDirection::Inout);
}
_ => {}
}
}
for fc_iri in self
.fc_direction_none
.intersection(&self.fc_public_access)
.cloned()
.collect::<Vec<_>>()
{
self.fc_directions
.entry(fc_iri)
.or_insert(FcDirection::Inout);
}
for triple in self.input.triples() {
let subject = match triple.subject.as_iri() {
Some(iri) => iri.as_str(),
None => continue,
};
if self.typed_subjects.get(subject).map(String::as_str) != Some(v2::SBOL2_MAPS_TO) {
continue;
}
let info = self.mapsto_info.entry(subject.to_owned()).or_default();
match triple.predicate.as_str() {
v2::SBOL2_LOCAL => {
if let Some(iri) = triple.object.as_iri() {
info.local = Some(iri.as_str().to_owned());
}
}
v2::SBOL2_REMOTE => {
if let Some(iri) = triple.object.as_iri() {
info.remote = Some(iri.as_str().to_owned());
}
}
v2::SBOL2_REFINEMENT => {
if let Some(iri) = triple.object.as_iri() {
info.refinement = Some(iri.as_str().to_owned());
}
}
v2::SBOL2_DISPLAY_ID => {
if let Some(lit) = triple.object.as_literal() {
info.display_id = Some(lit.value().to_owned());
}
}
_ => {}
}
}
Ok(())
}
fn owning_top_level(&self, subject: &str) -> String {
let mut current = subject.to_owned();
while let Some(parent) = self.owned_by.get(¤t) {
current = parent.clone();
}
current
}
fn convert(&mut self) {
let n = self.input.triples().len();
for i in 0..n {
let triple = self.input.triples()[i].clone();
self.handle_triple(&triple);
}
self.emit_synthesized_triples();
}
fn handle_triple(&mut self, triple: &Triple) {
let predicate = triple.predicate.as_str();
if !predicate.starts_with(v2::SBOL2_NS)
&& let Some(subject_iri) = triple.subject.as_iri()
&& let Some(target) = self.sa_to_subcomponent.get(subject_iri.as_str()).cloned()
&& (predicate != v3::RDF_TYPE
|| triple.object.as_iri().map(|i| i.as_str())
!= Some(v2::SBOL2_SEQUENCE_ANNOTATION))
{
self.preserve_collapsed_sa_metadata(triple, &target);
return;
}
if predicate == v3::RDF_TYPE {
self.handle_type_triple(triple);
return;
}
if predicate.starts_with(v2::SBOL2_NS) {
self.handle_sbol2_predicate(triple);
return;
}
if let Some(local) = predicate.strip_prefix(v2::BACKPORT_SBOL3_PREFIX) {
let restored = format!("{}{local}", v3::SBOL_NS);
let rewritten = self.identity.rewrite_triple(triple);
self.output_triples.push(Triple {
subject: rewritten.subject,
predicate: Iri::new_unchecked(restored),
object: rewritten.object,
});
return;
}
self.output_triples
.push(self.identity.rewrite_triple(triple));
}
fn handle_type_triple(&mut self, triple: &Triple) {
let object_iri = match triple.object.as_iri() {
Some(iri) => iri.as_str(),
None => {
self.output_triples
.push(self.identity.rewrite_triple(triple));
return;
}
};
if object_iri == v2::SBOL2_SEQUENCE_ANNOTATION
&& let Some(subject_iri) = triple.subject.as_iri()
&& self.sa_to_subcomponent.contains_key(subject_iri.as_str())
{
self.report
.push(UpgradeWarning::SequenceAnnotationWithComponent {
annotation: subject_iri.as_str().to_owned(),
});
self.report.counts.sequence_annotations_collapsed += 1;
return;
}
if object_iri == v2::SBOL2_MAPS_TO {
return;
}
let subject = self.identity.rewrite_resource(&triple.subject);
let v3_type: Option<&'static str> = match object_iri {
v2::SBOL2_COMPONENT_DEFINITION | v2::SBOL2_MODULE_DEFINITION => {
Some(v3::SBOL_COMPONENT_CLASS)
}
v2::SBOL2_COMPONENT | v2::SBOL2_MODULE | v2::SBOL2_FUNCTIONAL_COMPONENT => {
Some(v3::SBOL_SUB_COMPONENT_CLASS)
}
v2::SBOL2_SEQUENCE_ANNOTATION => Some(v3::SBOL_SEQUENCE_FEATURE_CLASS),
v2::SBOL2_SEQUENCE_CONSTRAINT => Some(v3::SBOL_CONSTRAINT_CLASS),
v2::SBOL2_SEQUENCE => Some(v3::SBOL_SEQUENCE_CLASS),
v2::SBOL2_MODEL => Some(v3::SBOL_MODEL_CLASS),
v2::SBOL2_INTERACTION => Some(v3::SBOL_INTERACTION_CLASS),
v2::SBOL2_PARTICIPATION => Some(v3::SBOL_PARTICIPATION_CLASS),
v2::SBOL2_COLLECTION => Some(v3::SBOL_COLLECTION_CLASS),
v2::SBOL2_IMPLEMENTATION => Some(v3::SBOL_IMPLEMENTATION_CLASS),
v2::SBOL2_ATTACHMENT => Some(v3::SBOL_ATTACHMENT_CLASS),
v2::SBOL2_EXPERIMENT => Some(v3::SBOL_EXPERIMENT_CLASS),
v2::SBOL2_EXPERIMENTAL_DATA => Some(v3::SBOL_EXPERIMENTAL_DATA_CLASS),
v2::SBOL2_COMBINATORIAL_DERIVATION => Some(v3::SBOL_COMBINATORIAL_DERIVATION_CLASS),
v2::SBOL2_VARIABLE_COMPONENT => Some(v3::SBOL_VARIABLE_FEATURE_CLASS),
v2::SBOL2_RANGE => Some(v3::SBOL_RANGE_CLASS),
v2::SBOL2_CUT => Some(v3::SBOL_CUT_CLASS),
v2::SBOL2_GENERIC_LOCATION => Some(v3::SBOL_LOCATION_CLASS),
other if other.starts_with(v2::SBOL2_NS) => {
if let Some(iri) = triple.subject.as_iri() {
self.report.push(UpgradeWarning::UnknownSbol2Type {
subject: iri.as_str().to_owned(),
sbol2_type: other.to_owned(),
});
}
let subject_has_known_type = triple
.subject
.as_iri()
.and_then(|iri| self.typed_subjects.get(iri.as_str()))
.is_some_and(|ty| is_known_sbol2_type(ty));
if self.options.preserve_backport && !subject_has_known_type {
self.output_triples.push(Triple {
subject,
predicate: Iri::from_static(v2::BACKPORT_SBOL2_TYPE),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(other))),
});
}
return;
}
_ => {
let mapped = values::map_biopax_type(object_iri).unwrap_or(object_iri);
self.output_triples.push(Triple {
subject,
predicate: triple.predicate.clone(),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(mapped))),
});
return;
}
};
if let Some(target) = v3_type {
self.output_triples.push(Triple {
subject: subject.clone(),
predicate: triple.predicate.clone(),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(target))),
});
match object_iri {
v2::SBOL2_COMPONENT_DEFINITION => {
self.report.counts.component_definitions += 1;
}
v2::SBOL2_MODULE_DEFINITION => {
self.report.counts.module_definitions += 1;
self.output_triples.push(Triple {
subject: subject.clone(),
predicate: Iri::from_static(v3::SBOL_TYPE),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(
"https://identifiers.org/SBO:0000241",
))),
});
}
v2::SBOL2_COMPONENT | v2::SBOL2_MODULE | v2::SBOL2_FUNCTIONAL_COMPONENT => {
self.report.counts.sub_components += 1;
}
v2::SBOL2_SEQUENCE_ANNOTATION => {
self.report.counts.sequence_features += 1;
}
_ => {}
}
if self.options.preserve_backport {
self.output_triples.push(Triple {
subject,
predicate: Iri::from_static(v2::BACKPORT_SBOL2_TYPE),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(object_iri))),
});
}
}
}
fn handle_sbol2_predicate(&mut self, triple: &Triple) {
let predicate = triple.predicate.as_str();
if self.try_collapse_sa_triple(triple) {
return;
}
if predicate == v2::SBOL2_MAPS_TO_PROP
&& let Some(target_iri) = triple.object.as_iri()
&& self.mapsto_info.contains_key(target_iri.as_str())
{
return;
}
if let Some(subject_iri) = triple.subject.as_iri()
&& self.mapsto_info.contains_key(subject_iri.as_str())
{
return;
}
let subject = self.identity.rewrite_resource(&triple.subject);
let object = self.identity.rewrite_term(&triple.object);
let renamed: Option<&'static str> = match predicate {
v2::SBOL2_DISPLAY_ID => Some(v3::SBOL_DISPLAY_ID),
v2::SBOL2_BUILT => Some(v3::SBOL_BUILT),
v2::SBOL2_TYPE => Some(v3::SBOL_TYPE),
v2::SBOL2_ROLE => Some(v3::SBOL_ROLE),
v2::SBOL2_ROLE_INTEGRATION => Some(v3::SBOL_ROLE_INTEGRATION),
v2::SBOL2_ELEMENTS => Some(v3::SBOL_ELEMENTS),
v2::SBOL2_SOURCE => Some(v3::SBOL_SOURCE),
v2::SBOL2_FORMAT => Some(v3::SBOL_FORMAT),
v2::SBOL2_SIZE => Some(v3::SBOL_SIZE),
v2::SBOL2_HASH => Some(v3::SBOL_HASH),
v2::SBOL2_HASH_ALGORITHM => Some(v3::SBOL_HASH_ALGORITHM),
v2::SBOL2_LANGUAGE => Some(v3::SBOL_LANGUAGE),
v2::SBOL2_FRAMEWORK => Some(v3::SBOL_FRAMEWORK),
v2::SBOL2_START => Some(v3::SBOL_START),
v2::SBOL2_END => Some(v3::SBOL_END),
v2::SBOL2_AT => Some(v3::SBOL_AT),
v2::SBOL2_SEQUENCE_PROP => Some(v3::SBOL_HAS_SEQUENCE),
v2::SBOL2_SEQUENCE_ANNOTATION_PROP => Some(v3::SBOL_HAS_FEATURE),
v2::SBOL2_SEQUENCE_CONSTRAINT_PROP => Some(v3::SBOL_HAS_CONSTRAINT),
v2::SBOL2_COMPONENT_PROP => Some(v3::SBOL_HAS_FEATURE),
v2::SBOL2_FUNCTIONAL_COMPONENT_PROP => Some(v3::SBOL_HAS_FEATURE),
v2::SBOL2_MODULE_PROP => Some(v3::SBOL_HAS_FEATURE),
v2::SBOL2_INTERACTION_PROP => Some(v3::SBOL_HAS_INTERACTION),
v2::SBOL2_PARTICIPATION_PROP => Some(v3::SBOL_HAS_PARTICIPATION),
v2::SBOL2_LOCATION_PROP => Some(v3::SBOL_HAS_LOCATION),
v2::SBOL2_DEFINITION => Some(v3::SBOL_INSTANCE_OF),
v2::SBOL2_VARIABLE_COMPONENT_PROP => Some(v3::SBOL_HAS_VARIABLE_FEATURE),
v2::SBOL2_OPERATOR => Some(v3::SBOL_CARDINALITY),
v2::SBOL2_VARIABLE => Some(v3::SBOL_VARIABLE),
v2::SBOL2_VARIANT => Some(v3::SBOL_VARIANT),
v2::SBOL2_VARIANT_COLLECTION => Some(v3::SBOL_VARIANT_COLLECTION),
v2::SBOL2_VARIANT_DERIVATION => Some(v3::SBOL_VARIANT_DERIVATION),
v2::SBOL2_MODEL_PROP => Some(v3::SBOL_HAS_MODEL),
v2::SBOL2_ATTACHMENT_PROP => Some(v3::SBOL_HAS_ATTACHMENT),
v2::SBOL2_RESTRICTION => Some(v3::SBOL_RESTRICTION),
v2::SBOL2_SUBJECT => Some(v3::SBOL_SUBJECT),
v2::SBOL2_OBJECT => Some(v3::SBOL_OBJECT),
v2::SBOL2_PARTICIPANT => Some(v3::SBOL_PARTICIPANT),
v2::SBOL2_STRATEGY => Some(v3::SBOL_STRATEGY),
v2::SBOL2_TEMPLATE => Some(v3::SBOL_TEMPLATE),
v2::SBOL2_MEMBER => Some(v3::SBOL_MEMBER),
v2::SBOL2_EXPERIMENTAL_DATA_PROP => Some(v3::SBOL_MEMBER),
_ => None,
};
if let Some(target) = renamed {
let mut object_with_value_rewrites =
self.rewrite_value(triple.predicate.as_str(), &object);
if predicate == v2::SBOL2_DISPLAY_ID
&& let Some(subject_iri) = triple.subject.as_iri()
&& let Some(override_did) =
self.location_display_id_overrides.get(subject_iri.as_str())
{
object_with_value_rewrites =
Term::Literal(sbol_rdf::Literal::simple(override_did.clone()));
}
let biopax_original = (predicate == v2::SBOL2_TYPE)
.then(|| object.as_iri().map(|i| i.as_str().to_owned()))
.flatten()
.filter(|iri| values::map_biopax_type(iri).is_some());
self.output_triples.push(Triple {
subject: subject.clone(),
predicate: Iri::from_static(target),
object: object_with_value_rewrites,
});
if self.options.preserve_backport
&& let Some(original) = biopax_original
{
self.output_triples.push(Triple {
subject,
predicate: Iri::from_static(v2::BACKPORT_BIOPAX_TYPE),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(original))),
});
}
return;
}
if predicate == v2::SBOL2_ENCODING {
let rewritten = self.rewrite_value(predicate, &object);
self.output_triples.push(Triple {
subject,
predicate: Iri::from_static(v3::SBOL_ENCODING),
object: rewritten,
});
return;
}
if predicate == v2::SBOL2_ORIENTATION {
let rewritten = self.rewrite_value(predicate, &object);
self.output_triples.push(Triple {
subject,
predicate: Iri::from_static(v3::SBOL_ORIENTATION),
object: rewritten,
});
return;
}
if self.options.preserve_backport {
let backport: Option<&'static str> = match predicate {
v2::SBOL2_PERSISTENT_IDENTITY => Some(v2::BACKPORT_SBOL2_PERSISTENT_IDENTITY),
v2::SBOL2_VERSION => Some(v2::BACKPORT_SBOL2_VERSION),
_ => None,
};
if let Some(target) = backport {
self.output_triples.push(Triple {
subject,
predicate: Iri::from_static(target),
object,
});
return;
}
}
if self.options.preserve_backport && predicate.starts_with(v2::SBOL2_NS) {
let local = &predicate[v2::SBOL2_NS.len()..];
let preserved = format!("{}{local}", v2::BACKPORT_SBOL2_PREFIX);
self.output_triples.push(Triple {
subject,
predicate: Iri::new_unchecked(preserved),
object,
});
}
}
fn preserve_collapsed_sa_metadata(&mut self, triple: &Triple, target_subcomponent: &str) {
if !self.options.preserve_backport {
return;
}
let predicate = triple.predicate.as_str();
if predicate == v2::SBOL2_DISPLAY_ID
|| predicate == v2::SBOL2_COMPONENT_PROP
|| predicate == v2::SBOL2_LOCATION_PROP
{
return;
}
let subject = Resource::Iri(Iri::new_unchecked(
self.identity.rewrite_iri(target_subcomponent).to_owned(),
));
let preserved = format!(
"{}{}",
v2::BACKPORT_SEQUENCE_ANNOTATION_PREDICATE_PREFIX,
hex_encode(predicate.as_bytes())
);
self.output_triples.push(Triple {
subject,
predicate: Iri::new_unchecked(preserved),
object: self.identity.rewrite_term(&triple.object),
});
}
fn try_collapse_sa_triple(&mut self, triple: &Triple) -> bool {
let predicate = triple.predicate.as_str();
let subject_iri = match triple.subject.as_iri() {
Some(iri) => iri.as_str(),
None => return false,
};
if predicate == v2::SBOL2_SEQUENCE_ANNOTATION_PROP
&& let Some(target_iri) = triple.object.as_iri()
&& self.sa_to_subcomponent.contains_key(target_iri.as_str())
{
return true;
}
let target_subcomponent = match self.sa_to_subcomponent.get(subject_iri) {
Some(target) => target.clone(),
None => return false,
};
if predicate == v2::SBOL2_LOCATION_PROP {
let new_subject = Resource::Iri(Iri::new_unchecked(
self.identity.rewrite_iri(&target_subcomponent).to_owned(),
));
let object = self.identity.rewrite_term(&triple.object);
self.output_triples.push(Triple {
subject: new_subject,
predicate: Iri::from_static(v3::SBOL_HAS_LOCATION),
object,
});
return true;
}
if predicate == v2::SBOL2_DISPLAY_ID && self.options.preserve_backport {
let new_subject = Resource::Iri(Iri::new_unchecked(
self.identity.rewrite_iri(&target_subcomponent).to_owned(),
));
self.output_triples.push(Triple {
subject: new_subject,
predicate: Iri::from_static(v2::BACKPORT_SEQUENCE_ANNOTATION_DISPLAY_ID),
object: triple.object.clone(),
});
return true;
}
if predicate == v2::SBOL2_COMPONENT_PROP {
return true;
}
self.preserve_collapsed_sa_metadata(triple, &target_subcomponent);
true
}
fn rewrite_value(&self, predicate_str: &str, object: &Term) -> Term {
let iri = match object.as_iri() {
Some(iri) => iri.as_str(),
None => return object.clone(),
};
let mapped = match predicate_str {
v2::SBOL2_ORIENTATION => values::map_orientation(iri),
v2::SBOL2_ENCODING => values::map_encoding(iri),
v2::SBOL2_TYPE => values::map_biopax_type(iri),
v2::SBOL2_RESTRICTION => values::map_restriction(iri),
v2::SBOL2_OPERATOR => values::map_operator(iri),
v2::SBOL2_STRATEGY => values::map_strategy(iri),
v2::SBOL2_ROLE_INTEGRATION => values::map_role_integration(iri),
_ => None,
};
match mapped {
Some(v3_iri) => Term::Resource(Resource::Iri(Iri::new_unchecked(v3_iri))),
None => object.clone(),
}
}
fn emit_synthesized_triples(&mut self) {
for (sbol2_iri, sbol2_type) in self.typed_subjects.clone().iter() {
if !is_top_level_sbol2(sbol2_type) {
continue;
}
let canonical = self.identity.rewrite_iri(sbol2_iri).to_owned();
if self.namespaced_subjects.contains(&canonical) {
continue;
}
self.namespaced_subjects.insert(canonical.clone());
let namespace = match self.identity.namespace_for(&canonical) {
Some(ns) => Some(ns.to_owned()),
None => self.fallback_namespace(sbol2_iri),
};
if let Some(namespace) = namespace {
self.output_triples.push(Triple {
subject: Resource::Iri(Iri::new_unchecked(canonical)),
predicate: Iri::from_static(v3::SBOL_HAS_NAMESPACE),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(namespace))),
});
}
}
let promoted: Vec<Triple> = self
.output_triples
.iter()
.filter_map(|t| match t.predicate.as_str() {
v2::DCTERMS_TITLE => Some(Triple {
subject: t.subject.clone(),
predicate: Iri::from_static(v3::SBOL_NAME),
object: t.object.clone(),
}),
v2::DCTERMS_DESCRIPTION => Some(Triple {
subject: t.subject.clone(),
predicate: Iri::from_static(v3::SBOL_DESCRIPTION),
object: t.object.clone(),
}),
_ => None,
})
.collect();
self.output_triples.extend(promoted);
self.infer_location_sequences();
self.synthesize_mapsto_decomposition();
self.synthesize_interfaces();
}
fn synthesize_interfaces(&mut self) {
let mut by_top_level: HashMap<String, Vec<(String, FcDirection)>> = HashMap::new();
for (fc_iri, direction) in self.fc_directions.clone() {
let top_v2 = self.owning_top_level(&fc_iri);
by_top_level
.entry(top_v2)
.or_default()
.push((fc_iri, direction));
}
for (top_v2, fcs) in by_top_level {
let top_v3 = self.identity.rewrite_iri(&top_v2).to_owned();
let (interface_display_id, interface_iri) =
next_available_child_iri(&top_v3, "Interface", &mut self.used_iris);
let interface_resource = Resource::Iri(Iri::new_unchecked(interface_iri.clone()));
self.output_triples.push(Triple {
subject: interface_resource.clone(),
predicate: Iri::from_static(v3::RDF_TYPE),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(v3::SBOL_INTERFACE_CLASS))),
});
self.output_triples.push(Triple {
subject: interface_resource.clone(),
predicate: Iri::from_static(v3::SBOL_DISPLAY_ID),
object: Term::Literal(sbol_rdf::Literal::simple(interface_display_id)),
});
for (fc_iri, direction) in fcs {
let fc_v3 = self.identity.rewrite_iri(&fc_iri).to_owned();
let fc_term = Term::Resource(Resource::Iri(Iri::new_unchecked(fc_v3.clone())));
let predicates: &[&'static str] = match direction {
FcDirection::Input => &[v3::SBOL_INPUT],
FcDirection::Output => &[v3::SBOL_OUTPUT],
FcDirection::Inout => &[v3::SBOL_NONDIRECTIONAL],
};
for predicate in predicates {
self.output_triples.push(Triple {
subject: interface_resource.clone(),
predicate: Iri::from_static(predicate),
object: fc_term.clone(),
});
}
}
self.output_triples.push(Triple {
subject: Resource::Iri(Iri::new_unchecked(top_v3)),
predicate: Iri::from_static(v3::SBOL_HAS_INTERFACE),
object: Term::Resource(interface_resource),
});
self.report.counts.interfaces_synthesized += 1;
}
}
fn synthesize_mapsto_decomposition(&mut self) {
let mapstos: Vec<(String, MapsToInfo)> = self
.mapsto_info
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let mut mapstos = mapstos;
mapstos.sort_by(|a, b| a.0.cmp(&b.0));
for (mapsto_iri, info) in mapstos {
let carrier = match self.owned_by.get(&mapsto_iri).cloned() {
Some(c) => c,
None => {
self.report.push(UpgradeWarning::UnresolvedMapsTo {
mapsto: mapsto_iri.clone(),
side: MapsToSide::Carrier,
});
continue;
}
};
let top_level_v2 = self.owning_top_level(&carrier);
let top_level = self.identity.rewrite_iri(&top_level_v2).to_owned();
let carrier_v3 = self.identity.rewrite_iri(&carrier).to_owned();
let local = match info.local.as_ref() {
Some(l) => self.identity.rewrite_iri(l).to_owned(),
None => {
self.report.push(UpgradeWarning::UnresolvedMapsTo {
mapsto: mapsto_iri.clone(),
side: MapsToSide::Local,
});
continue;
}
};
let remote = match info.remote.as_ref() {
Some(r) => self.identity.rewrite_iri(r).to_owned(),
None => {
self.report.push(UpgradeWarning::UnresolvedMapsTo {
mapsto: mapsto_iri.clone(),
side: MapsToSide::Remote,
});
continue;
}
};
let base_display_id = info
.display_id
.clone()
.unwrap_or_else(|| last_path_segment(&mapsto_iri).to_owned());
let (cref_display_id, cref_iri, constraint_display_id, constraint_iri) =
next_available_mapsto_iris(&top_level, &base_display_id, &mut self.used_iris);
self.emit_component_reference(
&top_level,
&cref_iri,
&cref_display_id,
&carrier_v3,
&remote,
);
if self.options.preserve_backport
&& let Some(refinement) = info.refinement.as_ref()
{
self.output_triples.push(Triple {
subject: Resource::Iri(Iri::new_unchecked(cref_iri.clone())),
predicate: Iri::from_static(v2::BACKPORT_MAPS_TO_REFINEMENT),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(refinement.clone()))),
});
}
if self.options.preserve_backport && cref_display_id != base_display_id {
self.output_triples.push(Triple {
subject: Resource::Iri(Iri::new_unchecked(cref_iri.clone())),
predicate: Iri::from_static(v2::BACKPORT_MAPS_TO_DISPLAY_ID),
object: Term::Literal(sbol_rdf::Literal::simple(base_display_id.clone())),
});
}
let refinement_kind = match info.refinement.as_deref() {
Some(v2::SBOL2_REFINEMENT_USE_LOCAL) => RefinementShape::UseLocal,
Some(v2::SBOL2_REFINEMENT_USE_REMOTE) | Some(v2::SBOL2_REFINEMENT_MERGE) => {
RefinementShape::UseRemote
}
Some(v2::SBOL2_REFINEMENT_VERIFY_IDENTICAL) | None => {
RefinementShape::VerifyIdentical
}
Some(other) => {
self.report.push(UpgradeWarning::UnsupportedRefinement {
mapsto: mapsto_iri.clone(),
refinement: other.to_owned(),
});
RefinementShape::VerifyIdentical
}
};
let (subject_iri, object_iri, restriction) = match refinement_kind {
RefinementShape::UseLocal => (&local, &cref_iri, v3::SBOL_REPLACES),
RefinementShape::UseRemote => (&cref_iri, &local, v3::SBOL_REPLACES),
RefinementShape::VerifyIdentical => (&cref_iri, &local, v3::SBOL_VERIFY_IDENTICAL),
};
self.emit_constraint(
&top_level,
&constraint_iri,
&constraint_display_id,
subject_iri,
object_iri,
restriction,
);
self.report.counts.mapstos_decomposed += 1;
}
}
fn emit_component_reference(
&mut self,
top_level: &str,
cref_iri: &str,
display_id: &str,
in_child_of: &str,
refers_to: &str,
) {
let cref_resource = Resource::Iri(Iri::new_unchecked(cref_iri.to_owned()));
let top_resource = Resource::Iri(Iri::new_unchecked(top_level.to_owned()));
self.output_triples.push(Triple {
subject: cref_resource.clone(),
predicate: Iri::from_static(v3::RDF_TYPE),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(
v3::SBOL_COMPONENT_REFERENCE_CLASS,
))),
});
self.output_triples.push(Triple {
subject: cref_resource.clone(),
predicate: Iri::from_static(v3::SBOL_DISPLAY_ID),
object: Term::Literal(sbol_rdf::Literal::simple(display_id)),
});
self.output_triples.push(Triple {
subject: cref_resource.clone(),
predicate: Iri::from_static(v3::SBOL_IN_CHILD_OF),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(in_child_of.to_owned()))),
});
self.output_triples.push(Triple {
subject: cref_resource.clone(),
predicate: Iri::from_static(v3::SBOL_REFERS_TO),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(refers_to.to_owned()))),
});
self.output_triples.push(Triple {
subject: top_resource,
predicate: Iri::from_static(v3::SBOL_HAS_FEATURE),
object: Term::Resource(cref_resource),
});
}
fn emit_constraint(
&mut self,
top_level: &str,
constraint_iri: &str,
display_id: &str,
subject_iri: &str,
object_iri: &str,
restriction: &'static str,
) {
let constraint_resource = Resource::Iri(Iri::new_unchecked(constraint_iri.to_owned()));
let top_resource = Resource::Iri(Iri::new_unchecked(top_level.to_owned()));
self.output_triples.push(Triple {
subject: constraint_resource.clone(),
predicate: Iri::from_static(v3::RDF_TYPE),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(v3::SBOL_CONSTRAINT_CLASS))),
});
self.output_triples.push(Triple {
subject: constraint_resource.clone(),
predicate: Iri::from_static(v3::SBOL_DISPLAY_ID),
object: Term::Literal(sbol_rdf::Literal::simple(display_id)),
});
self.output_triples.push(Triple {
subject: constraint_resource.clone(),
predicate: Iri::from_static(v3::SBOL_SUBJECT),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(subject_iri.to_owned()))),
});
self.output_triples.push(Triple {
subject: constraint_resource.clone(),
predicate: Iri::from_static(v3::SBOL_OBJECT),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(object_iri.to_owned()))),
});
self.output_triples.push(Triple {
subject: constraint_resource.clone(),
predicate: Iri::from_static(v3::SBOL_RESTRICTION),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(restriction.to_owned()))),
});
self.output_triples.push(Triple {
subject: top_resource,
predicate: Iri::from_static(v3::SBOL_HAS_CONSTRAINT),
object: Term::Resource(constraint_resource),
});
}
fn infer_location_sequences(&mut self) {
let pairs: Vec<(String, String)> = self
.location_to_sa
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (sbol2_location, sa) in pairs {
let Some(cd) = self.sa_to_cd.get(&sa).cloned() else {
continue;
};
let sequences = self.cd_sequences.get(&cd).cloned().unwrap_or_default();
if sequences.len() != 1 {
self.report.push(UpgradeWarning::LocationWithoutSequence {
location: sbol2_location,
component: cd,
sequence_count: sequences.len(),
});
continue;
}
let location_canonical = self.identity.rewrite_iri(&sbol2_location).to_owned();
let sequence_canonical = self.identity.rewrite_iri(&sequences[0]).to_owned();
self.output_triples.push(Triple {
subject: Resource::Iri(Iri::new_unchecked(location_canonical)),
predicate: Iri::from_static(v3::SBOL_HAS_SEQUENCE),
object: Term::Resource(Resource::Iri(Iri::new_unchecked(sequence_canonical))),
});
self.report.counts.locations_with_inferred_sequence += 1;
}
}
fn fallback_namespace(&mut self, sbol2_iri: &str) -> Option<String> {
if let Some(origin) = url_origin(sbol2_iri) {
return Some(origin);
}
if let Some(default) = self.options.default_namespace.as_ref() {
self.report.push(UpgradeWarning::NamespaceFallback {
subject: sbol2_iri.to_owned(),
source: NamespaceSource::DefaultOption,
});
return Some(default.as_str().to_owned());
}
self.report.push(UpgradeWarning::NamespaceFallback {
subject: sbol2_iri.to_owned(),
source: NamespaceSource::None,
});
None
}
}
fn is_top_level_sbol2(sbol2_type: &str) -> bool {
matches!(
sbol2_type,
v2::SBOL2_COMPONENT_DEFINITION
| v2::SBOL2_MODULE_DEFINITION
| v2::SBOL2_SEQUENCE
| v2::SBOL2_MODEL
| v2::SBOL2_COLLECTION
| v2::SBOL2_IMPLEMENTATION
| v2::SBOL2_ATTACHMENT
| v2::SBOL2_EXPERIMENT
| v2::SBOL2_EXPERIMENTAL_DATA
| v2::SBOL2_COMBINATORIAL_DERIVATION
) || is_prov_top_level(sbol2_type)
}
fn is_prov_top_level(type_iri: &str) -> bool {
matches!(
type_iri,
v3::PROV_ACTIVITY | v3::PROV_AGENT_CLASS | v3::PROV_PLAN
)
}
fn type_precedence(type_iri: &str) -> u8 {
if is_known_sbol2_type(type_iri) || is_prov_top_level(type_iri) {
2
} else if type_iri.starts_with(v2::SBOL2_NS) {
1
} else {
0
}
}
fn is_known_sbol2_type(type_iri: &str) -> bool {
matches!(
type_iri,
v2::SBOL2_COMPONENT_DEFINITION
| v2::SBOL2_MODULE_DEFINITION
| v2::SBOL2_COMPONENT
| v2::SBOL2_MODULE
| v2::SBOL2_FUNCTIONAL_COMPONENT
| v2::SBOL2_SEQUENCE_ANNOTATION
| v2::SBOL2_SEQUENCE_CONSTRAINT
| v2::SBOL2_SEQUENCE
| v2::SBOL2_MODEL
| v2::SBOL2_INTERACTION
| v2::SBOL2_PARTICIPATION
| v2::SBOL2_COLLECTION
| v2::SBOL2_IMPLEMENTATION
| v2::SBOL2_ATTACHMENT
| v2::SBOL2_EXPERIMENT
| v2::SBOL2_EXPERIMENTAL_DATA
| v2::SBOL2_COMBINATORIAL_DERIVATION
| v2::SBOL2_VARIABLE_COMPONENT
| v2::SBOL2_RANGE
| v2::SBOL2_CUT
| v2::SBOL2_GENERIC_LOCATION
| v2::SBOL2_MAPS_TO
)
}
use crate::iri_util::last_iri_segment as last_path_segment;
fn next_available_child_iri(
parent: &str,
base_display_id: &str,
used: &mut HashSet<String>,
) -> (String, String) {
let mut counter: usize = 1;
loop {
let display_id = if counter == 1 {
base_display_id.to_owned()
} else {
format!("{base_display_id}_{counter}")
};
let iri = format!("{parent}/{display_id}");
if used.insert(iri.clone()) {
return (display_id, iri);
}
counter += 1;
}
}
fn next_available_mapsto_iris(
top_level: &str,
base_display_id: &str,
used: &mut HashSet<String>,
) -> (String, String, String, String) {
let mut counter: usize = 1;
loop {
let display_id = if counter == 1 {
base_display_id.to_owned()
} else {
format!("{base_display_id}_{counter}")
};
let cref_iri = format!("{top_level}/{display_id}");
let constraint_display_id = format!("{display_id}_constraint");
let constraint_iri = format!("{top_level}/{constraint_display_id}");
if !used.contains(&cref_iri) && !used.contains(&constraint_iri) {
used.insert(cref_iri.clone());
used.insert(constraint_iri.clone());
return (display_id, cref_iri, constraint_display_id, constraint_iri);
}
counter += 1;
}
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
pub fn canonical_nt_line(triple: &Triple) -> String {
const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
let subject = match &triple.subject {
Resource::Iri(iri) => format!("<{}>", iri.as_str()),
Resource::BlankNode(b) => format!("_:{}", b.as_str()),
other => unreachable!("unhandled Resource variant: {other:?}"),
};
let predicate = format!("<{}>", triple.predicate.as_str());
let object = match &triple.object {
Term::Resource(Resource::Iri(iri)) => format!("<{}>", iri.as_str()),
Term::Resource(Resource::BlankNode(b)) => format!("_:{}", b.as_str()),
Term::Literal(literal) => {
let escaped = escape_nt_string(literal.value());
if let Some(lang) = literal.language() {
format!("\"{escaped}\"@{lang}")
} else if literal.datatype().as_str() == XSD_STRING {
format!("\"{escaped}\"")
} else {
format!("\"{escaped}\"^^<{}>", literal.datatype().as_str())
}
}
other => unreachable!("unhandled Term variant: {other:?}"),
};
format!("{subject} {predicate} {object} .")
}
fn escape_nt_string(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'\\' => 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 || c as u32 == 0x7F => {
out.push_str(&format!("\\u{:04X}", c as u32));
}
_ => out.push(c),
}
}
out
}
fn url_origin(iri: &str) -> Option<String> {
if let Some((scheme, rest)) = iri.split_once("://") {
let authority_end = rest.find('/').unwrap_or(rest.len());
let host = &rest[..authority_end];
if !scheme.is_empty() && !host.is_empty() {
return Some(format!("{scheme}://{host}"));
}
}
if let Some(after_urn) = iri.strip_prefix("urn:")
&& !after_urn.is_empty()
{
let nid_end = after_urn.find([':', '/']).unwrap_or(after_urn.len());
let nid = &after_urn[..nid_end];
if !nid.is_empty() {
return Some(format!("urn:{nid}"));
}
}
None
}