#![allow(clippy::result_large_err)]
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::error::ValidationError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ItemType {
Solution,
UseCase,
Scenario,
SystemRequirement,
SystemArchitecture,
HardwareRequirement,
SoftwareRequirement,
HardwareDetailedDesign,
SoftwareDetailedDesign,
}
impl ItemType {
pub fn all() -> &'static [ItemType] {
&[
ItemType::Solution,
ItemType::UseCase,
ItemType::Scenario,
ItemType::SystemRequirement,
ItemType::SystemArchitecture,
ItemType::HardwareRequirement,
ItemType::SoftwareRequirement,
ItemType::HardwareDetailedDesign,
ItemType::SoftwareDetailedDesign,
]
}
pub fn display_name(&self) -> &'static str {
match self {
ItemType::Solution => "Solution",
ItemType::UseCase => "Use Case",
ItemType::Scenario => "Scenario",
ItemType::SystemRequirement => "System Requirement",
ItemType::SystemArchitecture => "System Architecture",
ItemType::HardwareRequirement => "Hardware Requirement",
ItemType::SoftwareRequirement => "Software Requirement",
ItemType::HardwareDetailedDesign => "Hardware Detailed Design",
ItemType::SoftwareDetailedDesign => "Software Detailed Design",
}
}
pub fn prefix(&self) -> &'static str {
match self {
ItemType::Solution => "SOL",
ItemType::UseCase => "UC",
ItemType::Scenario => "SCEN",
ItemType::SystemRequirement => "SYSREQ",
ItemType::SystemArchitecture => "SYSARCH",
ItemType::HardwareRequirement => "HWREQ",
ItemType::SoftwareRequirement => "SWREQ",
ItemType::HardwareDetailedDesign => "HWDD",
ItemType::SoftwareDetailedDesign => "SWDD",
}
}
pub fn requires_specification(&self) -> bool {
matches!(
self,
ItemType::SystemRequirement
| ItemType::HardwareRequirement
| ItemType::SoftwareRequirement
)
}
pub fn is_root(&self) -> bool {
matches!(self, ItemType::Solution)
}
pub fn is_leaf(&self) -> bool {
matches!(
self,
ItemType::HardwareDetailedDesign | ItemType::SoftwareDetailedDesign
)
}
pub fn required_parent_type(&self) -> Option<ItemType> {
match self {
ItemType::Solution => None,
ItemType::UseCase => Some(ItemType::Solution),
ItemType::Scenario => Some(ItemType::UseCase),
ItemType::SystemRequirement => Some(ItemType::Scenario),
ItemType::SystemArchitecture => Some(ItemType::SystemRequirement),
ItemType::HardwareRequirement => Some(ItemType::SystemArchitecture),
ItemType::SoftwareRequirement => Some(ItemType::SystemArchitecture),
ItemType::HardwareDetailedDesign => Some(ItemType::HardwareRequirement),
ItemType::SoftwareDetailedDesign => Some(ItemType::SoftwareRequirement),
}
}
pub fn traceability_field(&self) -> Option<&'static str> {
match self {
ItemType::Solution => None,
ItemType::UseCase | ItemType::Scenario => Some("refines"),
ItemType::SystemRequirement
| ItemType::HardwareRequirement
| ItemType::SoftwareRequirement => Some("derives_from"),
ItemType::SystemArchitecture
| ItemType::HardwareDetailedDesign
| ItemType::SoftwareDetailedDesign => Some("satisfies"),
}
}
pub fn yaml_value(&self) -> &'static str {
match self {
ItemType::Solution => "solution",
ItemType::UseCase => "use_case",
ItemType::Scenario => "scenario",
ItemType::SystemRequirement => "system_requirement",
ItemType::SystemArchitecture => "system_architecture",
ItemType::HardwareRequirement => "hardware_requirement",
ItemType::SoftwareRequirement => "software_requirement",
ItemType::HardwareDetailedDesign => "hardware_detailed_design",
ItemType::SoftwareDetailedDesign => "software_detailed_design",
}
}
pub fn traceability_config(&self) -> Option<TraceabilityConfig> {
match self {
ItemType::Solution => None,
ItemType::UseCase => Some(TraceabilityConfig {
relationship_field: "refines",
parent_type: ItemType::Solution,
}),
ItemType::Scenario => Some(TraceabilityConfig {
relationship_field: "refines",
parent_type: ItemType::UseCase,
}),
ItemType::SystemRequirement => Some(TraceabilityConfig {
relationship_field: "derives_from",
parent_type: ItemType::Scenario,
}),
ItemType::SystemArchitecture => Some(TraceabilityConfig {
relationship_field: "satisfies",
parent_type: ItemType::SystemRequirement,
}),
ItemType::HardwareRequirement | ItemType::SoftwareRequirement => {
Some(TraceabilityConfig {
relationship_field: "derives_from",
parent_type: ItemType::SystemArchitecture,
})
}
ItemType::HardwareDetailedDesign => Some(TraceabilityConfig {
relationship_field: "satisfies",
parent_type: ItemType::HardwareRequirement,
}),
ItemType::SoftwareDetailedDesign => Some(TraceabilityConfig {
relationship_field: "satisfies",
parent_type: ItemType::SoftwareRequirement,
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TraceabilityConfig {
pub relationship_field: &'static str,
pub parent_type: ItemType,
}
impl fmt::Display for ItemType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.display_name())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ItemId(String);
impl ItemId {
pub fn new(id: impl Into<String>) -> Result<Self, ValidationError> {
let id = id.into();
if id.is_empty() {
return Err(ValidationError::InvalidId {
id: id.clone(),
reason: "Item ID cannot be empty".to_string(),
});
}
if !id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(ValidationError::InvalidId {
id: id.clone(),
reason:
"Item ID must contain only alphanumeric characters, hyphens, and underscores"
.to_string(),
});
}
Ok(Self(id))
}
pub fn new_unchecked(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ItemId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for ItemId {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpstreamRefs {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refines: Vec<ItemId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub derives_from: Vec<ItemId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub satisfies: Vec<ItemId>,
}
impl UpstreamRefs {
pub fn all_ids(&self) -> Vec<&ItemId> {
let mut ids = Vec::new();
ids.extend(self.refines.iter());
ids.extend(self.derives_from.iter());
ids.extend(self.satisfies.iter());
ids
}
pub fn is_empty(&self) -> bool {
self.refines.is_empty() && self.derives_from.is_empty() && self.satisfies.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DownstreamRefs {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub is_refined_by: Vec<ItemId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub derives: Vec<ItemId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub is_satisfied_by: Vec<ItemId>,
}
impl DownstreamRefs {
pub fn all_ids(&self) -> Vec<&ItemId> {
let mut ids = Vec::new();
ids.extend(self.is_refined_by.iter());
ids.extend(self.derives.iter());
ids.extend(self.is_satisfied_by.iter());
ids
}
pub fn is_empty(&self) -> bool {
self.is_refined_by.is_empty() && self.derives.is_empty() && self.is_satisfied_by.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ItemAttributes {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub specification: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<ItemId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub justified_by: Option<Vec<ItemId>>,
}
use crate::model::metadata::SourceLocation;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
pub id: ItemId,
pub item_type: ItemType,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub source: SourceLocation,
#[serde(default)]
pub upstream: UpstreamRefs,
#[serde(default)]
pub downstream: DownstreamRefs,
#[serde(default)]
pub attributes: ItemAttributes,
}
impl Item {
pub fn all_references(&self) -> Vec<&ItemId> {
let mut refs = Vec::new();
refs.extend(self.upstream.all_ids());
refs.extend(self.downstream.all_ids());
refs.extend(self.attributes.depends_on.iter());
if let Some(justified_by) = &self.attributes.justified_by {
refs.extend(justified_by.iter());
}
refs
}
}
#[derive(Debug, Default)]
pub struct ItemBuilder {
id: Option<ItemId>,
item_type: Option<ItemType>,
name: Option<String>,
description: Option<String>,
source: Option<SourceLocation>,
upstream: UpstreamRefs,
downstream: DownstreamRefs,
attributes: ItemAttributes,
}
impl ItemBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn id(mut self, id: ItemId) -> Self {
self.id = Some(id);
self
}
pub fn item_type(mut self, item_type: ItemType) -> Self {
self.item_type = Some(item_type);
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn source(mut self, source: SourceLocation) -> Self {
self.source = Some(source);
self
}
pub fn upstream(mut self, upstream: UpstreamRefs) -> Self {
self.upstream = upstream;
self
}
pub fn downstream(mut self, downstream: DownstreamRefs) -> Self {
self.downstream = downstream;
self
}
pub fn specification(mut self, spec: impl Into<String>) -> Self {
self.attributes.specification = Some(spec.into());
self
}
pub fn platform(mut self, platform: impl Into<String>) -> Self {
self.attributes.platform = Some(platform.into());
self
}
pub fn depends_on(mut self, id: ItemId) -> Self {
self.attributes.depends_on.push(id);
self
}
pub fn attributes(mut self, attrs: ItemAttributes) -> Self {
self.attributes = attrs;
self
}
pub fn build(self) -> Result<Item, ValidationError> {
let id = self.id.ok_or_else(|| ValidationError::MissingField {
field: "id".to_string(),
file: self
.source
.as_ref()
.map(|s| s.file_path.display().to_string())
.unwrap_or_default(),
})?;
let item_type = self
.item_type
.ok_or_else(|| ValidationError::MissingField {
field: "type".to_string(),
file: self
.source
.as_ref()
.map(|s| s.file_path.display().to_string())
.unwrap_or_default(),
})?;
let name = self.name.ok_or_else(|| ValidationError::MissingField {
field: "name".to_string(),
file: self
.source
.as_ref()
.map(|s| s.file_path.display().to_string())
.unwrap_or_default(),
})?;
let source = self.source.ok_or_else(|| ValidationError::MissingField {
field: "source".to_string(),
file: String::new(),
})?;
if item_type.requires_specification() && self.attributes.specification.is_none() {
return Err(ValidationError::MissingField {
field: "specification".to_string(),
file: source.file_path.display().to_string(),
});
}
Ok(Item {
id,
item_type,
name,
description: self.description,
source,
upstream: self.upstream,
downstream: self.downstream,
attributes: self.attributes,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_item_id_valid() {
assert!(ItemId::new("SOL-001").is_ok());
assert!(ItemId::new("UC_002").is_ok());
assert!(ItemId::new("SYSREQ-123-A").is_ok());
}
#[test]
fn test_item_id_invalid() {
assert!(ItemId::new("").is_err());
assert!(ItemId::new("SOL 001").is_err());
assert!(ItemId::new("SOL.001").is_err());
}
#[test]
fn test_item_type_display() {
assert_eq!(ItemType::Solution.display_name(), "Solution");
assert_eq!(
ItemType::SystemRequirement.display_name(),
"System Requirement"
);
}
#[test]
fn test_item_type_requires_specification() {
assert!(ItemType::SystemRequirement.requires_specification());
assert!(ItemType::HardwareRequirement.requires_specification());
assert!(ItemType::SoftwareRequirement.requires_specification());
assert!(!ItemType::Solution.requires_specification());
assert!(!ItemType::Scenario.requires_specification());
}
#[test]
fn test_item_builder() {
let source = SourceLocation {
repository: PathBuf::from("/repo"),
file_path: PathBuf::from("docs/SOL-001.md"),
line: 1,
git_ref: None,
};
let item = ItemBuilder::new()
.id(ItemId::new_unchecked("SOL-001"))
.item_type(ItemType::Solution)
.name("Test Solution")
.source(source)
.build();
assert!(item.is_ok());
let item = item.unwrap();
assert_eq!(item.id.as_str(), "SOL-001");
assert_eq!(item.name, "Test Solution");
}
}