1use std::collections::BTreeMap;
11
12use crate::error::{Error, Result};
13
14pub type Id = Vec<String>;
16
17#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub struct Model {
20 pub elements: Vec<Element>,
21 pub relations: Vec<Relation>,
22 pub deployment: Deployment,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Element {
27 pub id: Id,
28 pub kind: ElementKind,
29 pub title: String,
30 pub description: Option<String>,
32 pub technology: Option<String>,
35 pub path: Option<String>,
38 pub tags: Vec<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ElementKind {
43 Container,
45 Component,
47 External(String),
50}
51
52impl ElementKind {
53 pub fn keyword(&self) -> &str {
56 match self {
57 ElementKind::Container => "container",
58 ElementKind::Component => "component",
59 ElementKind::External(name) => name,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
68pub enum RelationKind {
69 Implements,
70 Constructs,
71 Calls,
72 NamesType,
73 Uses,
74}
75
76impl RelationKind {
77 pub fn keyword(self) -> &'static str {
79 match self {
80 RelationKind::Implements => "implements",
81 RelationKind::Constructs => "constructs",
82 RelationKind::Calls => "calls",
83 RelationKind::NamesType => "names",
84 RelationKind::Uses => "uses",
85 }
86 }
87
88 pub const ALL: [RelationKind; 5] = [
90 RelationKind::Implements,
91 RelationKind::Constructs,
92 RelationKind::Calls,
93 RelationKind::NamesType,
94 RelationKind::Uses,
95 ];
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Relation {
100 pub source: Id,
101 pub target: Id,
102 pub kind: RelationKind,
103 pub items: Vec<String>,
105 pub technology: Option<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Default)]
114pub struct Deployment {
115 pub nodes: Vec<DeploymentNode>,
116 pub instances: Vec<DeploymentInstance>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct DeploymentNode {
121 pub id: Id,
122 pub kind: String,
123 pub title: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct DeploymentInstance {
128 pub id: Id,
129 pub element: Id,
131}
132
133pub fn is_lineal(a: &[String], b: &[String]) -> bool {
137 a.starts_with(b) || b.starts_with(a)
138}
139
140pub fn dotted(id: &[String]) -> String {
142 id.join(".")
143}
144
145pub const RESERVED: &[&str] = &[
152 "BottomTop",
153 "LeftRight",
154 "RightLeft",
155 "TopBottom",
156 "and",
157 "autoLayout",
158 "border",
159 "color",
160 "deploymentNode",
161 "description",
162 "dynamic",
163 "dynamicPredicateGroup",
164 "exclude",
165 "extend",
166 "extends",
167 "false",
168 "from",
169 "global",
170 "head",
171 "icon",
172 "iconColor",
173 "iconPosition",
174 "iconSize",
175 "icons",
176 "import",
177 "include",
178 "includeAncestors",
179 "instanceOf",
180 "is",
181 "it",
182 "kind",
183 "likec4lib",
184 "line",
185 "link",
186 "metadata",
187 "multiple",
188 "navigateTo",
189 "not",
190 "notation",
191 "notes",
192 "of",
193 "opacity",
194 "or",
195 "order",
196 "padding",
197 "predicate",
198 "predicateGroup",
199 "rank",
200 "rgb",
201 "rgba",
202 "shape",
203 "size",
204 "specification",
205 "style",
206 "styleGroup",
207 "summary",
208 "tag",
209 "tail",
210 "technology",
211 "textSize",
212 "this",
213 "title",
214 "true",
215 "variant",
216 "view",
217 "views",
218 "where",
219 "with",
220];
221
222pub fn sanitize_segment(segment: &str) -> String {
226 let mut out = String::with_capacity(segment.len() + 1);
227 if segment.chars().next().is_some_and(|c| c.is_ascii_digit()) {
228 out.push('_');
229 }
230 for c in segment.chars() {
231 out.push(if c.is_ascii_alphanumeric() || c == '_' {
232 c
233 } else {
234 '_'
235 });
236 }
237 if RESERVED.contains(&out.as_str()) {
238 out.push('_');
239 }
240 out
241}
242
243pub fn sanitize_id(id: &[String]) -> String {
245 id.iter()
246 .map(|s| sanitize_segment(s))
247 .collect::<Vec<_>>()
248 .join(".")
249}
250
251impl Model {
252 pub fn normalize(&mut self) {
257 self.elements.sort_by(|a, b| a.id.cmp(&b.id));
258
259 let mut merged: BTreeMap<(Id, Id), Relation> = BTreeMap::new();
260 for rel in self.relations.drain(..) {
261 let key = (rel.source.clone(), rel.target.clone());
262 match merged.get_mut(&key) {
263 None => {
264 merged.insert(key, rel);
265 }
266 Some(existing) => {
267 existing.kind = existing.kind.min(rel.kind);
268 existing.items.extend(rel.items);
269 if existing.technology.is_none() {
270 existing.technology = rel.technology;
271 }
272 }
273 }
274 }
275 self.relations = merged
276 .into_values()
277 .map(|mut rel| {
278 rel.items.sort();
279 rel.items.dedup();
280 rel
281 })
282 .collect();
283 }
284
285 pub fn merge(&mut self, other: Model) {
287 self.elements.extend(other.elements);
288 self.relations.extend(other.relations);
289 self.deployment.nodes.extend(other.deployment.nodes);
290 self.deployment.instances.extend(other.deployment.instances);
291 self.normalize();
292 }
293
294 pub fn validate(&self) -> Result<()> {
297 let mut seen: BTreeMap<String, &Id> = BTreeMap::new();
298 for element in &self.elements {
299 let key = sanitize_id(&element.id);
300 if let Some(first) = seen.insert(key.clone(), &element.id) {
301 return Err(Error::DuplicateId {
302 id: key,
303 first: dotted(first),
304 second: dotted(&element.id),
305 });
306 }
307 }
308 Ok(())
309 }
310
311 pub fn element(&self, id: &[String]) -> Option<&Element> {
312 self.elements.iter().find(|e| e.id == id)
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 fn id(s: &str) -> Id {
321 s.split('.').map(str::to_string).collect()
322 }
323
324 fn element(s: &str) -> Element {
325 Element {
326 id: id(s),
327 kind: ElementKind::Component,
328 title: s.rsplit('.').next().unwrap().to_string(),
329 description: None,
330 technology: None,
331 path: None,
332 tags: vec![],
333 }
334 }
335
336 fn relation(source: &str, target: &str, kind: RelationKind, items: &[&str]) -> Relation {
337 Relation {
338 source: id(source),
339 target: id(target),
340 kind,
341 items: items.iter().map(|s| s.to_string()).collect(),
342 technology: None,
343 }
344 }
345
346 #[test]
347 fn precedence_is_the_declared_order_strongest_first() {
348 assert!(RelationKind::Implements < RelationKind::Constructs);
349 assert!(RelationKind::Constructs < RelationKind::Calls);
350 assert!(RelationKind::Calls < RelationKind::NamesType);
351 assert!(RelationKind::NamesType < RelationKind::Uses);
352 }
353
354 #[test]
355 fn every_kind_has_a_distinct_keyword() {
356 let mut keywords: Vec<_> = RelationKind::ALL.iter().map(|k| k.keyword()).collect();
357 keywords.sort();
358 keywords.dedup();
359 assert_eq!(
360 keywords,
361 ["calls", "constructs", "implements", "names", "uses"]
362 );
363 }
364
365 #[test]
366 fn an_ancestor_and_its_descendant_are_lineal_in_both_directions() {
367 assert!(is_lineal(&id("a"), &id("a.b.c")));
368 assert!(is_lineal(&id("a.b.c"), &id("a")));
369 }
370
371 #[test]
372 fn an_element_is_lineal_with_itself() {
373 assert!(is_lineal(&id("a.b"), &id("a.b")));
374 }
375
376 #[test]
377 fn siblings_are_not_lineal() {
378 assert!(!is_lineal(&id("a.b"), &id("a.c")));
379 }
380
381 #[test]
382 fn a_shared_prefix_string_is_not_lineage() {
383 assert!(!is_lineal(&id("a.b"), &id("a.bc")));
385 }
386
387 #[test]
388 fn a_hyphen_becomes_an_underscore() {
389 assert_eq!(sanitize_segment("playwright-rs"), "playwright_rs");
390 }
391
392 #[test]
393 fn a_leading_digit_is_prefixed() {
394 assert_eq!(sanitize_segment("3d"), "_3d");
395 }
396
397 #[test]
398 fn a_reserved_word_gets_a_trailing_underscore() {
399 assert_eq!(sanitize_segment("view"), "view_");
400 assert_eq!(sanitize_segment("link"), "link_");
401 assert_eq!(sanitize_segment("icons"), "icons_");
403 assert_eq!(sanitize_segment("summary"), "summary_");
404 }
405
406 #[test]
407 fn model_and_element_are_not_reserved() {
408 assert_eq!(sanitize_segment("model"), "model");
411 assert_eq!(sanitize_segment("element"), "element");
412 }
413
414 #[test]
415 fn the_reserved_list_is_sorted_and_unique() {
416 let mut sorted = RESERVED.to_vec();
417 sorted.sort_unstable();
418 sorted.dedup();
419 assert_eq!(sorted, RESERVED);
420 }
421
422 #[test]
423 fn a_plain_identifier_is_unchanged() {
424 assert_eq!(sanitize_segment("server_v2"), "server_v2");
425 }
426
427 #[test]
428 fn sanitizing_an_id_keeps_the_dots() {
429 assert_eq!(sanitize_id(&id("play-wright.a.1x")), "play_wright.a._1x");
430 }
431
432 #[test]
433 fn normalize_sorts_elements_by_id() {
434 let mut model = Model {
435 elements: vec![element("b"), element("a.z"), element("a")],
436 ..Default::default()
437 };
438 model.normalize();
439 let ids: Vec<_> = model.elements.iter().map(|e| dotted(&e.id)).collect();
440 assert_eq!(ids, ["a", "a.z", "b"]);
441 }
442
443 #[test]
444 fn normalize_merges_relations_on_the_pair_keeping_the_strongest_kind() {
445 let mut model = Model {
446 relations: vec![
447 relation("a", "b", RelationKind::Uses, &["Y"]),
448 relation("a", "b", RelationKind::Calls, &["X"]),
449 ],
450 ..Default::default()
451 };
452 model.normalize();
453 assert_eq!(
454 model.relations,
455 vec![relation("a", "b", RelationKind::Calls, &["X", "Y"])]
456 );
457 }
458
459 #[test]
460 fn normalize_dedups_items_within_a_relation() {
461 let mut model = Model {
462 relations: vec![relation("a", "b", RelationKind::Uses, &["Y", "X", "Y"])],
463 ..Default::default()
464 };
465 model.normalize();
466 assert_eq!(model.relations[0].items, ["X", "Y"]);
467 }
468
469 #[test]
470 fn normalize_sorts_relations_by_source_then_target() {
471 let mut model = Model {
472 relations: vec![
473 relation("b", "a", RelationKind::Uses, &[]),
474 relation("a", "c", RelationKind::Uses, &[]),
475 relation("a", "b", RelationKind::Uses, &[]),
476 ],
477 ..Default::default()
478 };
479 model.normalize();
480 let pairs: Vec<_> = model
481 .relations
482 .iter()
483 .map(|r| (dotted(&r.source), dotted(&r.target)))
484 .collect();
485 assert_eq!(
486 pairs,
487 [
488 ("a".into(), "b".into()),
489 ("a".into(), "c".into()),
490 ("b".into(), "a".into())
491 ]
492 );
493 }
494
495 #[test]
496 fn normalize_keeps_the_first_technology_seen() {
497 let mut first = relation("a", "b", RelationKind::Uses, &[]);
498 first.technology = Some("stdio".into());
499 let mut model = Model {
500 relations: vec![
501 relation("a", "b", RelationKind::Uses, &[]),
502 first,
503 relation("a", "b", RelationKind::Uses, &[]),
504 ],
505 ..Default::default()
506 };
507 model.normalize();
508 assert_eq!(model.relations[0].technology.as_deref(), Some("stdio"));
509 }
510
511 #[test]
512 fn merge_appends_and_normalizes() {
513 let mut model = Model {
514 elements: vec![element("b")],
515 ..Default::default()
516 };
517 model.merge(Model {
518 elements: vec![element("a")],
519 relations: vec![relation("a", "b", RelationKind::Uses, &["X"])],
520 ..Default::default()
521 });
522 let ids: Vec<_> = model.elements.iter().map(|e| dotted(&e.id)).collect();
523 assert_eq!(ids, ["a", "b"]);
524 assert_eq!(model.relations.len(), 1);
525 }
526
527 #[test]
528 fn two_ids_that_sanitize_alike_are_a_duplicate_naming_both() {
529 let model = Model {
530 elements: vec![element("foo-bar"), element("foo_bar")],
531 ..Default::default()
532 };
533 match model.validate() {
534 Err(Error::DuplicateId { id, first, second }) => {
535 assert_eq!(id, "foo_bar");
536 assert_eq!(first, "foo-bar");
537 assert_eq!(second, "foo_bar");
538 }
539 other => panic!("expected DuplicateId, got {other:?}"),
540 }
541 }
542
543 #[test]
544 fn an_id_that_appears_twice_is_a_duplicate() {
545 let model = Model {
546 elements: vec![element("app"), element("app")],
547 ..Default::default()
548 };
549 match model.validate() {
550 Err(Error::DuplicateId { id, first, second }) => {
551 assert_eq!(
552 (id.as_str(), first.as_str(), second.as_str()),
553 ("app", "app", "app")
554 );
555 }
556 other => panic!("expected DuplicateId, got {other:?}"),
557 }
558 }
559
560 #[test]
561 fn distinct_ids_validate() {
562 let model = Model {
563 elements: vec![element("a"), element("a.b"), element("b")],
564 ..Default::default()
565 };
566 assert!(model.validate().is_ok());
567 }
568
569 #[test]
570 fn element_finds_by_exact_id() {
571 let model = Model {
572 elements: vec![element("a"), element("a.b")],
573 ..Default::default()
574 };
575 assert_eq!(
576 model.element(&id("a.b")).map(|e| e.title.as_str()),
577 Some("b")
578 );
579 assert_eq!(model.element(&id("a.b.c")), None);
580 }
581}