use serde::{Deserialize, Serialize};
use super::{
ChartSpec, Dash, DiagramOrientation, DiagramRole, DiagramSpacing, MainPath, NoteSpec, PortSide,
flow::validate_notes,
sequence::is_identifier,
validate_text,
view::{ViewScope, ViewSpec},
};
use crate::error::{ChartError, ChartWarning};
pub(crate) const MAX_COMPONENTS: usize = 40;
pub(crate) const MAX_CONNECTIONS: usize = 80;
pub(crate) const MAX_BOUNDARIES: usize = 12;
pub(crate) const MAX_BOUNDARY_DEPTH: usize = 4;
const MAX_PERSPECTIVES: usize = 8;
const MAX_CATEGORIES: usize = 8;
const MAX_INTERFACES: usize = 4;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ArchitectureSpec {
pub components: Vec<ComponentSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connections: Vec<ConnectionSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub boundaries: Vec<BoundarySpec>,
#[serde(default, skip_serializing_if = "MainPath::is_empty")]
pub main_path: MainPath,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<NoteSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub same_rank: Vec<Vec<String>>,
#[serde(default, skip_serializing_if = "DiagramOrientation::is_auto")]
pub orientation: DiagramOrientation,
#[serde(default, skip_serializing_if = "DiagramSpacing::is_normal")]
pub spacing: DiagramSpacing,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub shared_trunks: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub views: Vec<ViewSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_view: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub perspectives: Vec<PerspectiveSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perspective: Option<String>,
#[serde(skip)]
pub(crate) scope: Option<ViewScope>,
#[serde(skip)]
pub(crate) environment: Option<(String, usize)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ComponentSpec {
pub id: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sublabel: Option<String>,
#[serde(default, skip_serializing_if = "ComponentKind::is_service")]
pub kind: ComponentKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
#[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
pub boundary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<DiagramRole>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub provides: Vec<InterfaceSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct InterfaceSpec {
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PerspectiveSpec {
pub key: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub categories: Vec<CategorySpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CategorySpec {
pub label: String,
#[serde(default)]
pub components: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connections: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ComponentKind {
Person,
Frontend,
#[default]
Service,
Database,
Queue,
Storage,
Cache,
Security,
External,
Node,
Artifact,
}
impl ComponentKind {
#[allow(clippy::trivially_copy_pass_by_ref)]
const fn is_service(&self) -> bool {
matches!(self, Self::Service)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConnectionSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub from: String,
pub to: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub technology: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dash: Option<Dash>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weight: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<DiagramRole>,
#[serde(default, skip_serializing_if = "ConnectionDirection::is_forward")]
pub direction: ConnectionDirection,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip)]
pub(crate) model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_side: Option<PortSide>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_side: Option<PortSide>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to_interface: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionDirection {
#[default]
Forward,
Both,
None,
}
impl ConnectionDirection {
#[allow(clippy::trivially_copy_pass_by_ref)]
pub(crate) const fn is_forward(&self) -> bool {
matches!(self, Self::Forward)
}
pub(crate) const fn sign(self) -> &'static str {
match self {
Self::Forward => "→",
Self::Both => "↔",
Self::None => "—",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BoundarySpec {
pub id: String,
pub label: String,
#[serde(default, rename = "in", skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
impl ArchitectureSpec {
pub(crate) fn component(&self, id: &str) -> Option<usize> {
self.components
.iter()
.position(|component| component.id == id)
}
pub(crate) fn required(&self, connection: &ConnectionSpec) -> Option<(usize, usize)> {
let id = connection.to_interface.as_deref()?;
let component = self.component(&connection.to)?;
let interface = self.components[component]
.provides
.iter()
.position(|interface| interface.id == id)?;
Some((component, interface))
}
pub(crate) fn boundary(&self, id: &str) -> Option<usize> {
self.boundaries
.iter()
.position(|boundary| boundary.id == id)
}
pub(crate) fn shown_perspective(&self) -> Option<&PerspectiveSpec> {
let key = self.perspective.as_deref()?;
self.perspectives
.iter()
.find(|perspective| perspective.key == key)
}
pub(crate) fn connection_category(&self, index: usize) -> Option<usize> {
let connection = &self.connections[index];
let id = connection.id.as_ref().or(connection.model_id.as_ref())?;
self.shown_perspective()?
.categories
.iter()
.position(|category| category.connections.contains(id))
}
pub(crate) fn check_perspective(&self, key: &str, path: &str) -> Result<(), ChartError> {
if self
.perspectives
.iter()
.any(|perspective| perspective.key == key)
{
return Ok(());
}
Err(ChartError::new(
"unknown_perspective",
path,
format!("no perspective has the key \"{key}\""),
))
}
pub(crate) fn is_directed(&self) -> bool {
self.connections
.iter()
.all(|connection| connection.direction.is_forward())
}
pub(crate) fn on_main_path(&self, index: usize) -> bool {
let connection = &self.connections[index];
self.main_path_steps()
.windows(2)
.any(|pair| pair[0] == connection.from && pair[1] == connection.to)
}
pub(crate) fn main_path_steps(&self) -> Vec<String> {
self.main_path
.steps(&self.connection_ids())
.expect("validated main paths have a way")
}
fn connection_ids(&self) -> impl Iterator<Item = (&str, &str)> + Clone {
self.connections
.iter()
.map(|connection| (connection.from.as_str(), connection.to.as_str()))
}
pub(crate) fn chain(&self, index: usize) -> Option<Vec<usize>> {
let mut chain = vec![index];
let mut at = index;
while let Some(parent) = self.boundaries[at].parent.as_deref() {
at = self.boundary(parent)?;
if chain.contains(&at) {
return None;
}
chain.push(at);
}
chain.reverse();
Some(chain)
}
}
impl ChartSpec {
pub fn with_perspective(&self, key: &str) -> Result<Self, ChartError> {
self.validate()?;
let keys: Vec<&str> = self
.architecture
.iter()
.flat_map(|architecture| &architecture.perspectives)
.map(|perspective| perspective.key.as_str())
.collect();
if !keys.contains(&key) {
let message = if keys.is_empty() {
"the specification has no perspectives; only an architecture diagram with perspectives has them"
.to_owned()
} else {
format!(
"no perspective has the key \"{key}\"; the perspectives are {}",
keys.iter()
.map(|key| format!("\"{key}\""))
.collect::<Vec<_>>()
.join(", ")
)
};
return Err(ChartError::new(
"unknown_perspective",
"/render/perspective",
message,
));
}
let mut spec = self.clone();
if let Some(architecture) = &mut spec.architecture {
architecture.perspective = Some(key.to_owned());
for view in &mut architecture.views {
view.perspective = Some(key.to_owned());
}
}
Ok(spec)
}
pub(crate) fn has_perspective(&self) -> bool {
self.architecture
.as_ref()
.is_some_and(|architecture| architecture.perspective.is_some())
}
pub(crate) fn has_interfaces(&self) -> bool {
self.architecture.as_ref().is_some_and(|architecture| {
architecture
.components
.iter()
.any(|component| !component.provides.is_empty())
})
}
pub(crate) fn has_connection_categories(&self) -> bool {
self.architecture
.as_ref()
.and_then(ArchitectureSpec::shown_perspective)
.is_some_and(|perspective| {
perspective
.categories
.iter()
.any(|category| !category.connections.is_empty())
})
}
pub(crate) fn has_component_symbols(&self) -> bool {
self.architecture.as_ref().is_some_and(|architecture| {
architecture
.components
.iter()
.any(|component| component.symbol.is_some())
})
}
pub(super) fn validate_architecture(&self) -> Result<Vec<ChartWarning>, ChartError> {
self.reject_map_options("an architecture diagram", "architecture.components")?;
let Some(architecture) = &self.architecture else {
return Err(ChartError::new(
"missing_architecture",
"/architecture",
"an architecture diagram requires an architecture block",
));
};
architecture.validate_boundaries()?;
architecture.validate_components()?;
architecture.validate_connections()?;
architecture.validate_perspectives()?;
validate_notes(
&architecture.notes,
"architecture",
|id| architecture.component(id).is_some() || architecture.boundary(id).is_some(),
"component or boundary",
)?;
self.validate_views()?;
Ok(Vec::new())
}
}
fn validate_id<'a>(
id: &str,
mut earlier: impl Iterator<Item = &'a str>,
path: &str,
) -> Result<(), ChartError> {
if !is_identifier(id) {
return Err(ChartError::new(
"invalid_id",
format!("{path}/id"),
"use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
));
}
if earlier.any(|other| other == id) {
return Err(ChartError::new(
"duplicate_id",
format!("{path}/id"),
format!("the id \"{id}\" is already taken by an earlier component or boundary"),
));
}
Ok(())
}
fn validate_interfaces(component: &ComponentSpec, path: &str) -> Result<(), ChartError> {
if component.provides.is_empty() {
return Ok(());
}
if matches!(component.kind, ComponentKind::Person | ComponentKind::Node) {
return Err(ChartError::new(
"option_not_supported",
format!("{path}/provides"),
"a person or a node provides no interface; give it to the component that implements it",
));
}
if component.provides.len() > MAX_INTERFACES {
return Err(ChartError::new(
"too_many_interfaces",
format!("{path}/provides"),
format!("a component provides at most {MAX_INTERFACES} interfaces"),
));
}
for (index, interface) in component.provides.iter().enumerate() {
let path = format!("{path}/provides/{index}");
if !is_identifier(&interface.id) {
return Err(ChartError::new(
"invalid_id",
format!("{path}/id"),
"use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
));
}
if component.provides[..index]
.iter()
.any(|other| other.id == interface.id)
{
return Err(ChartError::new(
"duplicate_id",
format!("{path}/id"),
format!(
"the id \"{}\" is already taken by an earlier interface of this component",
interface.id
),
));
}
validate_text(&interface.label, &format!("{path}/label"), 40)?;
}
Ok(())
}
const MAX_TAGS: usize = 8;
pub(super) fn validate_tag(tag: &str, path: &str) -> Result<(), ChartError> {
if is_identifier(tag) {
return Ok(());
}
Err(ChartError::new(
"invalid_tag",
path,
"use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
))
}
fn validate_tags(tags: &[String], path: &str) -> Result<(), ChartError> {
if tags.len() > MAX_TAGS {
return Err(ChartError::new(
"too_many_tags",
format!("{path}/tags"),
format!("at most {MAX_TAGS} tags are supported"),
));
}
for (at, tag) in tags.iter().enumerate() {
let path = format!("{path}/tags/{at}");
validate_tag(tag, &path)?;
if tags[..at].contains(tag) {
return Err(ChartError::new(
"duplicate_tag",
path,
format!("the tag \"{tag}\" is already listed"),
));
}
}
Ok(())
}
fn validate_connection_id(
id: &str,
earlier: &[ConnectionSpec],
path: &str,
) -> Result<(), ChartError> {
if !is_identifier(id) {
return Err(ChartError::new(
"invalid_id",
format!("{path}/id"),
"use 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
));
}
if earlier.iter().any(|other| other.id.as_deref() == Some(id)) {
return Err(ChartError::new(
"duplicate_id",
format!("{path}/id"),
format!("the id \"{id}\" is already taken by an earlier connection"),
));
}
Ok(())
}
impl ArchitectureSpec {
fn validate_boundaries(&self) -> Result<(), ChartError> {
if self.boundaries.len() > MAX_BOUNDARIES {
return Err(ChartError::new(
"too_many_boundaries",
"/architecture/boundaries",
format!("at most {MAX_BOUNDARIES} boundaries are supported"),
));
}
for (index, boundary) in self.boundaries.iter().enumerate() {
let path = format!("/architecture/boundaries/{index}");
let earlier = self.boundaries[..index]
.iter()
.map(|other| other.id.as_str());
validate_id(&boundary.id, earlier, &path)?;
validate_text(&boundary.label, &format!("{path}/label"), 60)?;
}
for (index, boundary) in self.boundaries.iter().enumerate() {
let path = format!("/architecture/boundaries/{index}/in");
if let Some(parent) = &boundary.parent
&& self.boundary(parent).is_none()
{
return Err(ChartError::new(
"unknown_boundary",
path,
format!("no boundary has the id \"{parent}\""),
));
}
let Some(chain) = self.chain(index) else {
return Err(ChartError::new(
"boundary_cycle",
path,
"boundaries lie in each other in a circle",
));
};
if chain.len() > MAX_BOUNDARY_DEPTH {
return Err(ChartError::new(
"boundaries_too_deep",
path,
format!("boundaries nest at most {MAX_BOUNDARY_DEPTH} deep"),
));
}
}
Ok(())
}
fn validate_components(&self) -> Result<(), ChartError> {
if self.components.is_empty() {
return Err(ChartError::new(
"empty_data",
"/architecture/components",
"provide at least one component",
));
}
if self.components.len() > MAX_COMPONENTS {
return Err(ChartError::new(
"too_many_nodes",
"/architecture/components",
format!("at most {MAX_COMPONENTS} components are supported"),
));
}
for (index, component) in self.components.iter().enumerate() {
let path = format!("/architecture/components/{index}");
let earlier = self
.boundaries
.iter()
.map(|boundary| boundary.id.as_str())
.chain(
self.components[..index]
.iter()
.map(|other| other.id.as_str()),
);
validate_id(&component.id, earlier, &path)?;
validate_text(&component.label, &format!("{path}/label"), 60)?;
validate_tags(&component.tags, &path)?;
if let Some(sublabel) = &component.sublabel {
validate_text(sublabel, &format!("{path}/sublabel"), 60)?;
}
if let Some(boundary) = &component.boundary
&& self.boundary(boundary).is_none()
{
return Err(ChartError::new(
"unknown_boundary",
format!("{path}/in"),
format!("no boundary has the id \"{boundary}\""),
));
}
if let Some(symbol) = &component.symbol
&& crate::symbols::find(symbol).is_none()
{
return Err(ChartError::new(
"unknown_symbol",
format!("{path}/symbol"),
format!(
"no symbol is named \"{symbol}\"; use one of {}",
crate::symbols::names()
),
));
}
validate_interfaces(component, &path)?;
}
for (index, boundary) in self.boundaries.iter().enumerate() {
let holds = self.components.iter().any(|component| {
component
.boundary
.as_deref()
.and_then(|id| self.boundary(id))
.and_then(|inner| self.chain(inner))
.is_some_and(|chain| chain.contains(&index))
});
if !holds {
return Err(ChartError::new(
"empty_boundary",
format!("/architecture/boundaries/{index}"),
format!("no component lies in \"{}\"", boundary.id),
));
}
}
Ok(())
}
fn validate_connections(&self) -> Result<(), ChartError> {
if self.connections.len() > MAX_CONNECTIONS {
return Err(ChartError::new(
"too_many_edges",
"/architecture/connections",
format!("at most {MAX_CONNECTIONS} connections are supported"),
));
}
for (index, connection) in self.connections.iter().enumerate() {
let path = format!("/architecture/connections/{index}");
if let Some(id) = &connection.id {
validate_connection_id(id, &self.connections[..index], &path)?;
}
for (field, id) in [("from", &connection.from), ("to", &connection.to)] {
if self.component(id).is_none() {
return Err(ChartError::new(
"unknown_node",
format!("{path}/{field}"),
format!("no component has the id \"{id}\""),
));
}
}
if let Some(label) = &connection.label {
validate_text(label, &format!("{path}/label"), 60)?;
}
if let Some(interface) = &connection.to_interface {
if self.required(connection).is_none() {
return Err(ChartError::new(
"unknown_interface",
format!("{path}/toInterface"),
format!(
"\"{}\" provides no interface with the id \"{interface}\"",
connection.to
),
));
}
if !connection.direction.is_forward() {
return Err(ChartError::new(
"option_not_supported",
format!("{path}/direction"),
"a connection that requires an interface runs forward, from the component that requires it",
));
}
if connection.to_side.is_some() {
return Err(ChartError::new(
"option_not_supported",
format!("{path}/toSide"),
"a connection that requires an interface ends at its ball, ahead of the component; it takes no toSide",
));
}
}
validate_tags(&connection.tags, &path)?;
super::flow::validate_weight(connection.weight, &path)?;
super::flow::validate_role(connection.role, connection.dash, &path)?;
super::flow::validate_sides(
(&connection.from, &connection.to),
(connection.from_side, connection.to_side),
&path,
)?;
if let Some(technology) = &connection.technology {
validate_text(technology, &format!("{path}/technology"), 40)?;
}
if let Some(step) = connection.step {
if !(1..=99).contains(&step) {
return Err(ChartError::new(
"invalid_step",
format!("{path}/step"),
"a step is a number from 1 to 99",
));
}
if self.connections[..index]
.iter()
.any(|earlier| earlier.step == Some(step))
{
return Err(ChartError::new(
"duplicate_step",
format!("{path}/step"),
format!("step {step} is already used by an earlier connection"),
));
}
}
}
self.main_path.validate(
"architecture",
|id| self.component(id).is_some(),
&self.connection_ids(),
("component", "connection"),
)?;
super::flow::validate_same_rank(
&self.same_rank,
"architecture",
|id| self.component(id).is_some(),
&self.connection_ids(),
"component",
)?;
Ok(())
}
fn validate_perspectives(&self) -> Result<(), ChartError> {
if self.perspectives.len() > MAX_PERSPECTIVES {
return Err(ChartError::new(
"too_many_perspectives",
"/architecture/perspectives",
format!("at most {MAX_PERSPECTIVES} perspectives are supported"),
));
}
for (index, perspective) in self.perspectives.iter().enumerate() {
let path = format!("/architecture/perspectives/{index}");
if !is_identifier(&perspective.key)
|| self.perspectives[..index]
.iter()
.any(|other| other.key == perspective.key)
{
return Err(ChartError::new(
"invalid_id",
format!("{path}/key"),
"use a key of its own: 1–64 ASCII letters, digits, hyphens, or underscores, starting with a letter",
));
}
validate_text(&perspective.title, &format!("{path}/title"), 40)?;
if let Some(description) = &perspective.description {
validate_text(description, &format!("{path}/description"), 200)?;
}
let categories = &perspective.categories;
if categories.is_empty() || categories.len() > MAX_CATEGORIES {
return Err(ChartError::new(
"invalid_categories",
format!("{path}/categories"),
format!("a perspective has 1 to {MAX_CATEGORIES} categories"),
));
}
let mut listed: Vec<&str> = Vec::new();
let mut connections: Vec<&str> = Vec::new();
for (at, category) in categories.iter().enumerate() {
let path = format!("{path}/categories/{at}");
validate_text(&category.label, &format!("{path}/label"), 40)?;
if categories[..at]
.iter()
.any(|other| other.label == category.label)
{
return Err(ChartError::new(
"duplicate_category",
format!("{path}/label"),
format!("the category \"{}\" is already listed", category.label),
));
}
for (member, id) in category.components.iter().enumerate() {
let path = format!("{path}/components/{member}");
if self.component(id).is_none() {
return Err(ChartError::new(
"unknown_node",
path,
format!("no component has the id \"{id}\""),
));
}
if listed.contains(&id.as_str()) {
return Err(ChartError::new(
"duplicate_member",
path,
format!("\"{id}\" already has a category in this perspective"),
));
}
listed.push(id);
}
for (member, id) in category.connections.iter().enumerate() {
let path = format!("{path}/connections/{member}");
if !self
.connections
.iter()
.any(|connection| connection.id.as_ref() == Some(id))
{
return Err(ChartError::new(
"unknown_connection",
path,
format!("no connection has the id \"{id}\""),
));
}
if connections.contains(&id.as_str()) {
return Err(ChartError::new(
"duplicate_member",
path,
format!(
"the connection \"{id}\" already has a category in this perspective"
),
));
}
connections.push(id);
}
}
}
if let Some(key) = &self.perspective {
self.check_perspective(key, "/architecture/perspective")?;
}
Ok(())
}
}