use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ProjectMetadata {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub usage: Vec<Dependency>,
}
impl ProjectMetadata {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
description: None,
usage: Vec::new(),
}
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn with_dependency(mut self, dep: Dependency) -> Self {
self.usage.push(dep);
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Dependency {
pub resource: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "versionConstraint")]
pub version_constraint: Option<String>,
}
impl Dependency {
pub fn new(resource: impl Into<String>) -> Self {
Self {
resource: resource.into(),
version_constraint: None,
}
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version_constraint = Some(version.into());
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PackageMetadata {
#[serde(default)]
pub index: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metamodel: Option<String>,
}
impl PackageMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn with_created(mut self, timestamp: impl Into<String>) -> Self {
self.created = Some(timestamp.into());
self
}
pub fn with_metamodel(mut self, uri: impl Into<String>) -> Self {
self.metamodel = Some(uri.into());
self
}
pub fn add_file(&mut self, namespace: impl Into<String>, file: impl Into<String>) {
self.index.insert(namespace.into(), file.into());
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ImportMetadata {
pub version: u32,
pub source: SourceInfo,
pub elements: HashMap<String, ElementMeta>,
}
impl ImportMetadata {
pub const CURRENT_VERSION: u32 = 1;
pub fn new() -> Self {
Self {
version: Self::CURRENT_VERSION,
source: SourceInfo::default(),
elements: HashMap::new(),
}
}
pub fn with_source(mut self, source: SourceInfo) -> Self {
self.source = source;
self
}
pub fn add_element(&mut self, qualified_name: impl Into<String>, meta: ElementMeta) {
self.elements.insert(qualified_name.into(), meta);
}
pub fn get_element(&self, qualified_name: &str) -> Option<&ElementMeta> {
self.elements.get(qualified_name)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SourceInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub imported_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_version: Option<String>,
}
impl SourceInfo {
pub fn from_path(path: impl Into<String>) -> Self {
Self {
path: Some(path.into()),
..Default::default()
}
}
pub fn with_format(mut self, format: impl Into<String>) -> Self {
self.format = Some(format.into());
self
}
pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
self.imported_at = Some(timestamp.into());
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ElementMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "originalId")]
pub original_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "declaredId")]
pub declared_id: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[serde(rename = "unmappedAttributes")]
pub unmapped_attributes: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(rename = "siblingOrder")]
pub sibling_order: Option<u32>,
}
impl ElementMeta {
pub fn with_id(id: impl Into<String>) -> Self {
Self {
original_id: Some(id.into()),
..Default::default()
}
}
pub fn with_declared_id(mut self, id: impl Into<String>) -> Self {
self.declared_id = Some(id.into());
self
}
pub fn with_unmapped(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.unmapped_attributes.insert(key.into(), value);
self
}
pub fn with_order(mut self, order: u32) -> Self {
self.sibling_order = Some(order);
self
}
pub fn element_id(&self) -> Option<&str> {
self.original_id.as_deref().or(self.declared_id.as_deref())
}
}
impl ImportMetadata {
pub fn read_from_file(path: impl AsRef<std::path::Path>) -> Result<Self, std::io::Error> {
let content = std::fs::read_to_string(path.as_ref())?;
serde_json::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn write_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<(), std::io::Error> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path.as_ref(), json)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_project_metadata() {
let project = ProjectMetadata::new("Vehicle Model", "1.0.0")
.with_description("A sample vehicle model")
.with_dependency(
Dependency::new("https://www.omg.org/spec/SysML/20250201/Systems-Library.kpar")
.with_version("2.0.0"),
);
let json = serde_json::to_string_pretty(&project).unwrap();
assert!(json.contains("\"name\": \"Vehicle Model\""));
assert!(json.contains("\"versionConstraint\": \"2.0.0\""));
let parsed: ProjectMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, "Vehicle Model");
assert_eq!(parsed.usage.len(), 1);
}
#[test]
fn test_package_metadata() {
let mut meta = PackageMetadata::new()
.with_created("2025-03-13T00:00:00Z")
.with_metamodel("https://www.omg.org/spec/SysML/20250201");
meta.add_file("CausationConnections", "CausationConnections.sysml");
meta.add_file("CauseAndEffect", "CauseAndEffect.sysml");
let json = serde_json::to_string_pretty(&meta).unwrap();
assert!(json.contains("\"CausationConnections\": \"CausationConnections.sysml\""));
assert!(
!json.contains("elements"),
"PackageMetadata should not have elements field"
);
let parsed: PackageMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.index.len(), 2);
assert_eq!(parsed.created.as_deref(), Some("2025-03-13T00:00:00Z"));
assert_eq!(
parsed.metamodel.as_deref(),
Some("https://www.omg.org/spec/SysML/20250201")
);
}
#[test]
fn test_import_metadata_with_element_ids() {
let mut meta = ImportMetadata::new()
.with_source(SourceInfo::from_path("model.xmi").with_format("xmi"));
meta.add_element("Package1", ElementMeta::with_id("pkg-1"));
meta.add_element("Package1::Vehicle", ElementMeta::with_id("vehicle-1"));
assert_eq!(meta.version, ImportMetadata::CURRENT_VERSION);
assert_eq!(
meta.get_element("Package1").unwrap().original_id.as_deref(),
Some("pkg-1")
);
assert_eq!(
meta.get_element("Package1::Vehicle")
.unwrap()
.original_id
.as_deref(),
Some("vehicle-1")
);
}
#[test]
fn test_element_meta_with_unmapped() {
let meta = ElementMeta::with_id("xyz-456")
.with_declared_id("MyElement")
.with_unmapped("customAttr", serde_json::json!(42))
.with_order(3);
assert_eq!(meta.original_id.as_deref(), Some("xyz-456"));
assert_eq!(meta.declared_id.as_deref(), Some("MyElement"));
assert_eq!(
meta.unmapped_attributes.get("customAttr"),
Some(&serde_json::json!(42))
);
assert_eq!(meta.sibling_order, Some(3));
assert_eq!(meta.element_id(), Some("xyz-456"));
}
#[test]
fn test_serialize_roundtrip() {
let mut meta = ImportMetadata::new()
.with_source(SourceInfo::from_path("model.xmi").with_format("xmi"));
meta.add_element("Package1", ElementMeta::with_id("pkg-1").with_order(0));
meta.add_element(
"Package1::Part1",
ElementMeta::with_id("part-1").with_unmapped("isIndividual", serde_json::json!(true)),
);
let json = serde_json::to_string_pretty(&meta).unwrap();
let parsed: ImportMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version, ImportMetadata::CURRENT_VERSION);
assert_eq!(parsed.source.path.as_deref(), Some("model.xmi"));
assert_eq!(parsed.elements.len(), 2);
let pkg = parsed.get_element("Package1").unwrap();
assert_eq!(pkg.original_id.as_deref(), Some("pkg-1"));
let part = parsed.get_element("Package1::Part1").unwrap();
assert_eq!(
part.unmapped_attributes.get("isIndividual"),
Some(&serde_json::json!(true))
);
}
#[test]
fn test_empty_metadata_serializes_minimal() {
let meta = PackageMetadata::new();
let json = serde_json::to_string(&meta).unwrap();
assert!(!json.contains("unmappedAttributes"));
assert!(!json.contains("elements"));
}
}