use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use super::{ChartSpec, Dash, DiagramOrientation, sequence::is_identifier, validate_text};
use crate::error::{ChartError, ChartWarning};
pub(crate) const MAX_CLASSES: usize = 40;
pub(crate) const MAX_RELATIONS: usize = 100;
const MAX_MEMBERS: usize = 30;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ClassesSpec {
pub classes: Vec<ClassSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relations: Vec<RelationSpec>,
#[serde(default, skip_serializing_if = "DiagramOrientation::is_auto")]
pub orientation: DiagramOrientation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ClassSpec {
pub id: String,
pub label: String,
#[serde(default, skip_serializing_if = "ClassKind::is_class")]
pub kind: ClassKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attributes: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub methods: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ClassKind {
#[default]
Class,
Interface,
Abstract,
Enum,
Entity,
}
impl ClassKind {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_class(&self) -> bool {
matches!(self, Self::Class)
}
pub(crate) const fn stereotype(self) -> Option<&'static str> {
match self {
Self::Class | Self::Entity => None,
Self::Interface => Some("«interface»"),
Self::Abstract => Some("«abstract»"),
Self::Enum => Some("«enumeration»"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RelationSpec {
pub from: String,
pub to: String,
#[serde(default, skip_serializing_if = "RelationKind::is_association")]
pub kind: RelationKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_cardinality: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_cardinality: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RelationKind {
#[default]
Association,
Inheritance,
Realization,
Composition,
Aggregation,
Dependency,
}
impl RelationKind {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_association(&self) -> bool {
matches!(self, Self::Association)
}
pub(crate) const fn dash(self) -> Option<Dash> {
match self {
Self::Realization | Self::Dependency => Some(Dash::Dashed),
_ => None,
}
}
}
impl ClassesSpec {
pub(crate) fn class(&self, id: &str) -> Option<usize> {
self.classes.iter().position(|class| class.id == id)
}
}
impl ChartSpec {
pub(super) fn validate_classes(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_map_options("a class diagram", "classes.classes")?;
let Some(classes) = &self.classes else {
return Err(ChartError::new(
"missing_classes",
"/classes",
"a class diagram requires a classes block",
));
};
if classes.classes.is_empty() {
return Err(ChartError::new(
"empty_data",
"/classes/classes",
"provide at least one class",
));
}
if classes.classes.len() > MAX_CLASSES {
return Err(ChartError::new(
"too_many_nodes",
"/classes/classes",
format!("at most {MAX_CLASSES} classes are supported"),
));
}
if classes.relations.len() > MAX_RELATIONS {
return Err(ChartError::new(
"too_many_edges",
"/classes/relations",
format!("at most {MAX_RELATIONS} relations are supported"),
));
}
classes.validate_classes()?;
classes.validate_relations()?;
Ok(Vec::new())
}
}
impl ClassesSpec {
fn validate_classes(&self) -> Result<(), ChartError> {
let mut ids = BTreeSet::new();
for (index, class) in self.classes.iter().enumerate() {
let path = format!("/classes/classes/{index}");
if !is_identifier(&class.id) {
return Err(ChartError::new(
"invalid_id",
format!("{path}/id"),
"use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
));
}
if !ids.insert(class.id.as_str()) {
return Err(ChartError::new(
"duplicate_id",
format!("{path}/id"),
format!(
"the id \"{}\" is already taken by an earlier class",
class.id
),
));
}
validate_text(&class.label, &format!("{path}/label"), 60)?;
for (name, members) in [
("attributes", &class.attributes),
("methods", &class.methods),
] {
if members.len() > MAX_MEMBERS {
return Err(ChartError::new(
"too_many_data_points",
format!("{path}/{name}"),
format!("at most {MAX_MEMBERS} {name} are supported"),
));
}
for (at, member) in members.iter().enumerate() {
validate_text(member, &format!("{path}/{name}/{at}"), 100)?;
}
}
}
Ok(())
}
fn validate_relations(&self) -> Result<(), ChartError> {
for (index, relation) in self.relations.iter().enumerate() {
let path = format!("/classes/relations/{index}");
for (name, id) in [("from", &relation.from), ("to", &relation.to)] {
if self.class(id).is_none() {
return Err(ChartError::new(
"unknown_node",
format!("{path}/{name}"),
format!("no class has the id \"{id}\""),
));
}
}
if let Some(label) = &relation.label {
validate_text(label, &format!("{path}/label"), 60)?;
}
for (name, cardinality) in [
("fromCardinality", &relation.from_cardinality),
("toCardinality", &relation.to_cardinality),
] {
if let Some(cardinality) = cardinality {
validate_text(cardinality, &format!("{path}/{name}"), 12)?;
}
}
}
Ok(())
}
}