use std::collections::BTreeMap;
use crate::error::{Error, Result};
pub type Id = Vec<String>;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Model {
pub elements: Vec<Element>,
pub relations: Vec<Relation>,
pub deployment: Deployment,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Element {
pub id: Id,
pub kind: ElementKind,
pub title: String,
pub description: Option<String>,
pub technology: Option<String>,
pub path: Option<String>,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ElementKind {
Container,
Component,
Bin,
Tests,
Examples,
External(String),
}
pub const SURVEY_KINDS: [ElementKind; 5] = [
ElementKind::Container,
ElementKind::Component,
ElementKind::Bin,
ElementKind::Tests,
ElementKind::Examples,
];
impl ElementKind {
pub fn keyword(&self) -> &str {
match self {
ElementKind::Container => "container",
ElementKind::Component => "component",
ElementKind::Bin => "bin",
ElementKind::Tests => "tests",
ElementKind::Examples => "examples",
ElementKind::External(name) => name,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum RelationKind {
Implements,
Constructs,
Calls,
NamesType,
Uses,
}
impl RelationKind {
pub fn keyword(self) -> &'static str {
match self {
RelationKind::Implements => "implements",
RelationKind::Constructs => "constructs",
RelationKind::Calls => "calls",
RelationKind::NamesType => "names",
RelationKind::Uses => "uses",
}
}
pub(crate) fn line(self) -> &'static str {
match self {
RelationKind::Implements | RelationKind::Constructs | RelationKind::Calls => "solid",
RelationKind::NamesType | RelationKind::Uses => "dotted",
}
}
pub(crate) fn head(self) -> &'static str {
match self {
RelationKind::Implements => "onormal",
RelationKind::Constructs => "diamond",
RelationKind::Calls => "vee",
RelationKind::NamesType => "dot",
RelationKind::Uses => "odot",
}
}
pub const ALL: [RelationKind; 5] = [
RelationKind::Implements,
RelationKind::Constructs,
RelationKind::Calls,
RelationKind::NamesType,
RelationKind::Uses,
];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Relation {
pub source: Id,
pub target: Id,
pub kind: RelationKind,
pub items: Vec<String>,
pub technology: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Deployment {
pub nodes: Vec<DeploymentNode>,
pub instances: Vec<DeploymentInstance>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeploymentNode {
pub id: Id,
pub kind: String,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeploymentInstance {
pub id: Id,
pub element: Id,
}
pub fn is_lineal(a: &[String], b: &[String]) -> bool {
a.starts_with(b) || b.starts_with(a)
}
pub fn dotted(id: &[String]) -> String {
id.join(".")
}
pub const RESERVED_GRAMMAR_VERSION: &str = "1.59.4";
pub const RESERVED: &[&str] = &[
"BottomTop",
"LeftRight",
"RightLeft",
"TopBottom",
"and",
"autoLayout",
"border",
"color",
"deploymentNode",
"description",
"dynamic",
"dynamicPredicateGroup",
"exclude",
"extend",
"extends",
"false",
"from",
"global",
"head",
"icon",
"iconColor",
"iconPosition",
"iconSize",
"icons",
"import",
"include",
"includeAncestors",
"instanceOf",
"is",
"it",
"kind",
"likec4lib",
"line",
"link",
"metadata",
"multiple",
"navigateTo",
"not",
"notation",
"notes",
"of",
"opacity",
"or",
"order",
"padding",
"predicate",
"predicateGroup",
"rank",
"rgb",
"rgba",
"shape",
"size",
"specification",
"style",
"styleGroup",
"summary",
"tag",
"tail",
"technology",
"textSize",
"this",
"title",
"true",
"variant",
"view",
"views",
"where",
"with",
];
pub fn sanitize_segment(segment: &str) -> String {
let mut out = String::with_capacity(segment.len() + 1);
if segment.chars().next().is_some_and(|c| c.is_ascii_digit()) {
out.push('_');
}
for c in segment.chars() {
out.push(if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
});
}
if RESERVED.contains(&out.as_str()) {
out.push('_');
}
out
}
pub fn sanitize_id(id: &[String]) -> String {
id.iter()
.map(|s| sanitize_segment(s))
.collect::<Vec<_>>()
.join(".")
}
impl Model {
pub fn normalize(&mut self) {
self.elements.sort_by(|a, b| a.id.cmp(&b.id));
let mut merged: BTreeMap<(Id, Id), Relation> = BTreeMap::new();
for rel in self.relations.drain(..) {
let key = (rel.source.clone(), rel.target.clone());
match merged.get_mut(&key) {
None => {
merged.insert(key, rel);
}
Some(existing) => {
existing.kind = existing.kind.min(rel.kind);
existing.items.extend(rel.items);
if existing.technology.is_none() {
existing.technology = rel.technology;
}
}
}
}
self.relations = merged
.into_values()
.map(|mut rel| {
rel.items.sort();
rel.items.dedup();
rel
})
.collect();
}
pub fn merge(&mut self, other: Model) {
self.elements.extend(other.elements);
self.relations.extend(other.relations);
self.deployment.nodes.extend(other.deployment.nodes);
self.deployment.instances.extend(other.deployment.instances);
self.normalize();
}
pub fn validate(&self) -> Result<()> {
let mut seen: BTreeMap<String, &Id> = BTreeMap::new();
for element in &self.elements {
let key = sanitize_id(&element.id);
if let Some(first) = seen.insert(key.clone(), &element.id) {
return Err(Error::DuplicateId {
id: key,
first: dotted(first),
second: dotted(&element.id),
});
}
}
Ok(())
}
pub fn element(&self, id: &[String]) -> Option<&Element> {
self.elements.iter().find(|e| e.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(s: &str) -> Id {
s.split('.').map(str::to_string).collect()
}
fn element(s: &str) -> Element {
Element {
id: id(s),
kind: ElementKind::Component,
title: s.rsplit('.').next().unwrap().to_string(),
description: None,
technology: None,
path: None,
tags: vec![],
}
}
fn relation(source: &str, target: &str, kind: RelationKind, items: &[&str]) -> Relation {
Relation {
source: id(source),
target: id(target),
kind,
items: items.iter().map(|s| s.to_string()).collect(),
technology: None,
}
}
#[test]
fn precedence_is_the_declared_order_strongest_first() {
assert!(RelationKind::Implements < RelationKind::Constructs);
assert!(RelationKind::Constructs < RelationKind::Calls);
assert!(RelationKind::Calls < RelationKind::NamesType);
assert!(RelationKind::NamesType < RelationKind::Uses);
}
#[test]
fn the_surveyed_kinds_are_every_kind_but_external_in_specification_order() {
assert_eq!(
SURVEY_KINDS.map(|kind| kind.keyword().to_string()),
["container", "component", "bin", "tests", "examples"]
);
}
#[test]
fn every_kind_has_a_distinct_keyword() {
let mut keywords: Vec<_> = RelationKind::ALL.iter().map(|k| k.keyword()).collect();
keywords.sort();
keywords.dedup();
assert_eq!(
keywords,
["calls", "constructs", "implements", "names", "uses"]
);
}
#[test]
fn an_ancestor_and_its_descendant_are_lineal_in_both_directions() {
assert!(is_lineal(&id("a"), &id("a.b.c")));
assert!(is_lineal(&id("a.b.c"), &id("a")));
}
#[test]
fn an_element_is_lineal_with_itself() {
assert!(is_lineal(&id("a.b"), &id("a.b")));
}
#[test]
fn siblings_are_not_lineal() {
assert!(!is_lineal(&id("a.b"), &id("a.c")));
}
#[test]
fn a_shared_prefix_string_is_not_lineage() {
assert!(!is_lineal(&id("a.b"), &id("a.bc")));
}
#[test]
fn a_hyphen_becomes_an_underscore() {
assert_eq!(sanitize_segment("playwright-rs"), "playwright_rs");
}
#[test]
fn a_leading_digit_is_prefixed() {
assert_eq!(sanitize_segment("3d"), "_3d");
}
#[test]
fn a_reserved_word_gets_a_trailing_underscore() {
assert_eq!(sanitize_segment("view"), "view_");
assert_eq!(sanitize_segment("link"), "link_");
assert_eq!(sanitize_segment("icons"), "icons_");
assert_eq!(sanitize_segment("summary"), "summary_");
}
#[test]
fn model_and_element_are_not_reserved() {
assert_eq!(sanitize_segment("model"), "model");
assert_eq!(sanitize_segment("element"), "element");
}
#[test]
fn the_reserved_list_is_sorted_and_unique() {
let mut sorted = RESERVED.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted, RESERVED);
}
#[test]
fn a_plain_identifier_is_unchanged() {
assert_eq!(sanitize_segment("server_v2"), "server_v2");
}
#[test]
fn sanitizing_an_id_keeps_the_dots() {
assert_eq!(sanitize_id(&id("play-wright.a.1x")), "play_wright.a._1x");
}
#[test]
fn normalize_sorts_elements_by_id() {
let mut model = Model {
elements: vec![element("b"), element("a.z"), element("a")],
..Default::default()
};
model.normalize();
let ids: Vec<_> = model.elements.iter().map(|e| dotted(&e.id)).collect();
assert_eq!(ids, ["a", "a.z", "b"]);
}
#[test]
fn normalize_merges_relations_on_the_pair_keeping_the_strongest_kind() {
let mut model = Model {
relations: vec![
relation("a", "b", RelationKind::Uses, &["Y"]),
relation("a", "b", RelationKind::Calls, &["X"]),
],
..Default::default()
};
model.normalize();
assert_eq!(
model.relations,
vec![relation("a", "b", RelationKind::Calls, &["X", "Y"])]
);
}
#[test]
fn normalize_dedups_items_within_a_relation() {
let mut model = Model {
relations: vec![relation("a", "b", RelationKind::Uses, &["Y", "X", "Y"])],
..Default::default()
};
model.normalize();
assert_eq!(model.relations[0].items, ["X", "Y"]);
}
#[test]
fn normalize_sorts_relations_by_source_then_target() {
let mut model = Model {
relations: vec![
relation("b", "a", RelationKind::Uses, &[]),
relation("a", "c", RelationKind::Uses, &[]),
relation("a", "b", RelationKind::Uses, &[]),
],
..Default::default()
};
model.normalize();
let pairs: Vec<_> = model
.relations
.iter()
.map(|r| (dotted(&r.source), dotted(&r.target)))
.collect();
assert_eq!(
pairs,
[
("a".into(), "b".into()),
("a".into(), "c".into()),
("b".into(), "a".into())
]
);
}
#[test]
fn normalize_keeps_the_first_technology_seen() {
let mut first = relation("a", "b", RelationKind::Uses, &[]);
first.technology = Some("stdio".into());
let mut model = Model {
relations: vec![
relation("a", "b", RelationKind::Uses, &[]),
first,
relation("a", "b", RelationKind::Uses, &[]),
],
..Default::default()
};
model.normalize();
assert_eq!(model.relations[0].technology.as_deref(), Some("stdio"));
}
#[test]
fn merge_appends_and_normalizes() {
let mut model = Model {
elements: vec![element("b")],
..Default::default()
};
model.merge(Model {
elements: vec![element("a")],
relations: vec![relation("a", "b", RelationKind::Uses, &["X"])],
..Default::default()
});
let ids: Vec<_> = model.elements.iter().map(|e| dotted(&e.id)).collect();
assert_eq!(ids, ["a", "b"]);
assert_eq!(model.relations.len(), 1);
}
#[test]
fn two_ids_that_sanitize_alike_are_a_duplicate_naming_both() {
let model = Model {
elements: vec![element("foo-bar"), element("foo_bar")],
..Default::default()
};
match model.validate() {
Err(Error::DuplicateId { id, first, second }) => {
assert_eq!(id, "foo_bar");
assert_eq!(first, "foo-bar");
assert_eq!(second, "foo_bar");
}
other => panic!("expected DuplicateId, got {other:?}"),
}
}
#[test]
fn an_id_that_appears_twice_is_a_duplicate() {
let model = Model {
elements: vec![element("app"), element("app")],
..Default::default()
};
match model.validate() {
Err(Error::DuplicateId { id, first, second }) => {
assert_eq!(
(id.as_str(), first.as_str(), second.as_str()),
("app", "app", "app")
);
}
other => panic!("expected DuplicateId, got {other:?}"),
}
}
#[test]
fn distinct_ids_validate() {
let model = Model {
elements: vec![element("a"), element("a.b"), element("b")],
..Default::default()
};
assert!(model.validate().is_ok());
}
#[test]
fn element_finds_by_exact_id() {
let model = Model {
elements: vec![element("a"), element("a.b")],
..Default::default()
};
assert_eq!(
model.element(&id("a.b")).map(|e| e.title.as_str()),
Some("b")
);
assert_eq!(model.element(&id("a.b.c")), None);
}
#[test]
fn each_relation_kind_is_drawn_with_its_line_and_head() {
assert_eq!(
RelationKind::ALL.map(|kind| (kind.keyword(), kind.line(), kind.head())),
[
("implements", "solid", "onormal"),
("constructs", "solid", "diamond"),
("calls", "solid", "vee"),
("names", "dotted", "dot"),
("uses", "dotted", "odot"),
]
);
}
}