1use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::Write;
11
12use crate::model::{Element, Id, Model, RelationKind, sanitize_id, sanitize_segment};
13
14pub const HEADER: &str = "// GENERATED by asbuilt. Do not edit; run asbuilt survey.";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct EmitOptions {
21 pub link_prefix: String,
25 pub theme: BTreeMap<String, String>,
29}
30
31impl EmitOptions {
32 pub fn for_output_path(output_path: &str) -> Self {
35 let depth = output_path
36 .split('/')
37 .filter(|s| !s.is_empty() && *s != ".")
38 .count()
39 .saturating_sub(1);
40 let link_prefix = if depth == 0 {
41 "./".to_string()
42 } else {
43 "../".repeat(depth)
44 };
45 Self {
46 link_prefix,
47 theme: BTreeMap::new(),
48 }
49 }
50
51 pub fn with_theme(mut self, theme: BTreeMap<String, String>) -> Self {
53 self.theme = theme;
54 self
55 }
56}
57
58fn quote(text: &str) -> String {
61 let mut out = String::with_capacity(text.len() + 2);
62 out.push('\'');
63 for c in text.chars() {
64 match c {
65 '\\' => out.push_str("\\\\"),
66 '\'' => out.push_str("\\'"),
67 '\r' => {}
68 c => out.push(c),
69 }
70 }
71 out.push('\'');
72 out
73}
74
75pub fn link_encode(path: &str) -> String {
79 let mut out = String::with_capacity(path.len());
80 for byte in path.bytes() {
81 let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/');
82 if keep {
83 out.push(byte as char);
84 } else {
85 out.push_str(&format!("%{byte:02X}"));
86 }
87 }
88 out
89}
90
91pub fn emit(model: &Model, options: &EmitOptions) -> String {
94 let mut model = model.clone();
95 model.normalize();
96
97 let mut out = String::new();
98 out.push_str(HEADER);
99 out.push('\n');
100 out.push('\n');
101 emit_specification(&mut out, &model, &options.theme);
102 out.push('\n');
103 emit_model(&mut out, &model, options);
104 out.push('\n');
105 emit_views(&mut out, &model);
106 out
107}
108
109pub fn theme_color_name(kind: &str) -> String {
111 format!("theme_{kind}")
112}
113
114fn emit_specification(out: &mut String, model: &Model, theme: &BTreeMap<String, String>) {
115 let mut kinds: BTreeSet<&str> = BTreeSet::new();
116 let mut tags: BTreeSet<&str> = BTreeSet::new();
117 for element in &model.elements {
118 kinds.insert(element.kind.keyword());
119 tags.extend(element.tags.iter().map(String::as_str));
120 }
121 out.push_str("specification {\n");
122 for kind in &kinds {
123 if let Some(color) = theme.get(*kind) {
124 writeln!(out, " color {} {color}", theme_color_name(kind)).unwrap();
125 }
126 }
127 for kind in &kinds {
128 if theme.contains_key(*kind) {
129 writeln!(
130 out,
131 " element {kind} {{\n style {{\n color {}\n }}\n }}",
132 theme_color_name(kind)
133 )
134 .unwrap();
135 } else {
136 writeln!(out, " element {kind}").unwrap();
137 }
138 }
139 for tag in tags {
140 writeln!(out, " tag {tag}").unwrap();
141 }
142 for kind in RelationKind::ALL {
143 writeln!(
144 out,
145 " relationship {} {{\n line {}\n head {}\n }}",
146 kind.keyword(),
147 kind.line(),
148 kind.head()
149 )
150 .unwrap();
151 }
152 out.push_str("}\n");
153}
154
155pub(crate) fn children_of<'a>(model: &'a Model, parent: &[String]) -> Vec<&'a Element> {
159 model
160 .elements
161 .iter()
162 .filter(|e| {
163 if parent.is_empty() {
164 e.id.len() == 1 || model.element(&e.id[..e.id.len() - 1]).is_none()
165 } else {
166 e.id.len() == parent.len() + 1 && e.id.starts_with(parent)
167 }
168 })
169 .collect()
170}
171
172fn emit_model(out: &mut String, model: &Model, options: &EmitOptions) {
173 out.push_str("model {\n");
174 for element in children_of(model, &[]) {
175 emit_element(out, model, element, options, 1);
176 }
177 if !model.relations.is_empty() {
178 out.push('\n');
179 }
180 for rel in &model.relations {
181 let indent = " ";
182 write!(
183 out,
184 "{indent}{} -[{}]-> {}",
185 sanitize_id(&rel.source),
186 rel.kind.keyword(),
187 sanitize_id(&rel.target)
188 )
189 .unwrap();
190 if !rel.items.is_empty() {
191 write!(out, " {}", quote(&rel.items.join(", "))).unwrap();
192 }
193 match &rel.technology {
194 Some(technology) => {
195 writeln!(
196 out,
197 " {{\n{indent} technology {}\n{indent}}}",
198 quote(technology)
199 )
200 .unwrap();
201 }
202 None => out.push('\n'),
203 }
204 }
205 out.push_str("}\n");
206}
207
208fn emit_element(
209 out: &mut String,
210 model: &Model,
211 element: &Element,
212 options: &EmitOptions,
213 depth: usize,
214) {
215 let indent = " ".repeat(depth);
216 let inner = " ".repeat(depth + 1);
217 let name = sanitize_segment(element.id.last().map(String::as_str).unwrap_or_default());
223 let children = children_of(model, &element.id);
224 let has_body = !element.tags.is_empty()
225 || element.description.is_some()
226 || element.technology.is_some()
227 || element.path.is_some()
228 || !children.is_empty();
229
230 write!(
231 out,
232 "{indent}{name} = {} {}",
233 element.kind.keyword(),
234 quote(&element.title)
235 )
236 .unwrap();
237 if !has_body {
238 out.push('\n');
239 return;
240 }
241 out.push_str(" {\n");
242 if !element.tags.is_empty() {
244 let tags: Vec<String> = element.tags.iter().map(|t| format!("#{t}")).collect();
245 writeln!(out, "{inner}{}", tags.join(" ")).unwrap();
246 }
247 if let Some(description) = &element.description {
248 writeln!(out, "{inner}description {}", quote(description)).unwrap();
249 }
250 if let Some(technology) = &element.technology {
251 writeln!(out, "{inner}technology {}", quote(technology)).unwrap();
252 }
253 if let Some(path) = &element.path {
254 writeln!(
255 out,
256 "{inner}metadata {{\n{inner} path {}\n{inner}}}",
257 quote(path)
258 )
259 .unwrap();
260 if path != "." {
264 writeln!(
265 out,
266 "{inner}link {}{}",
267 options.link_prefix,
268 link_encode(path)
269 )
270 .unwrap();
271 }
272 }
273 for child in children {
274 emit_element(out, model, child, options, depth + 1);
275 }
276 writeln!(out, "{indent}}}").unwrap();
277}
278
279pub fn view_id(id: &Id) -> String {
283 format!("view_{}", sanitize_id(id).replace('.', "_"))
284}
285
286pub fn view_ids(model: &Model) -> BTreeMap<Id, String> {
293 let mut used: BTreeSet<String> = BTreeSet::from(["index".to_string()]);
294 let mut names = BTreeMap::new();
295 for element in &model.elements {
296 if children_of(model, &element.id).is_empty() {
297 continue;
298 }
299 let base = view_id(&element.id);
300 let id = std::iter::once(base.clone())
301 .chain((2..).map(|n| format!("{base}_{n}")))
302 .find(|candidate| used.insert(candidate.clone()))
303 .expect("an unbounded sequence of candidates always has a free one");
304 names.insert(element.id.clone(), id);
305 }
306 names
307}
308
309fn emit_views(out: &mut String, model: &Model) {
310 out.push_str("views {\n");
311 let top: Vec<String> = children_of(model, &[])
312 .iter()
313 .map(|e| sanitize_id(&e.id))
314 .collect();
315 out.push_str(" view index {\n title 'Overview'\n");
316 if !top.is_empty() {
317 writeln!(out, " include {}", top.join(", ")).unwrap();
318 }
319 out.push_str(" autoLayout LeftRight\n }\n");
320 let names = view_ids(model);
321 for element in &model.elements {
322 let Some(id) = names.get(&element.id) else {
323 continue;
324 };
325 let fqn = sanitize_id(&element.id);
326 writeln!(
327 out,
328 "\n view {id} of {fqn} {{\n title {}\n include *\n autoLayout TopBottom\n }}",
329 quote(&element.id.join("."))
330 )
331 .unwrap();
332 }
333 out.push_str("}\n");
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::model::{ElementKind, Relation};
340
341 fn id(s: &str) -> Id {
342 s.split('.').map(str::to_string).collect()
343 }
344
345 fn element(s: &str, kind: ElementKind) -> Element {
346 Element {
347 id: id(s),
348 kind,
349 title: s.rsplit('.').next().unwrap().to_string(),
350 description: None,
351 technology: None,
352 path: None,
353 tags: vec![],
354 }
355 }
356
357 fn sample() -> Model {
358 let mut app = element("app", ElementKind::Container);
359 app.technology = Some("library crate".into());
360 app.path = Some("crates/app".into());
361 let mut server = element("app.server", ElementKind::Component);
362 server.description = Some("Where it's sent.\n\n- one\n- two".into());
363 server.path = Some("crates/app/src/server.rs".into());
364 let mut tests = element("app.tests", ElementKind::Tests);
365 tests.tags = vec!["tests".into()];
366 let mut driver = element("node_driver", ElementKind::External("process".into()));
367 driver.tags = vec!["external".into()];
368 driver.technology = Some("Node.js process".into());
369 Model {
370 elements: vec![tests, driver, server, app],
371 relations: vec![
372 Relation {
373 source: id("app.tests"),
374 target: id("app.server"),
375 kind: RelationKind::Calls,
376 items: vec!["Server".into(), "connect".into()],
377 technology: None,
378 },
379 Relation {
380 source: id("app.server"),
381 target: id("node_driver"),
382 kind: RelationKind::Uses,
383 items: vec!["spawns".into()],
384 technology: Some("stdio".into()),
385 },
386 ],
387 ..Default::default()
388 }
389 }
390
391 fn options() -> EmitOptions {
392 EmitOptions::for_output_path("docs/architecture/model.c4")
393 }
394
395 #[test]
396 fn the_header_is_the_first_line() {
397 let text = emit(&sample(), &options());
398 assert_eq!(text.lines().next(), Some(HEADER));
399 }
400
401 #[test]
402 fn the_specification_declares_every_element_kind_tag_and_relationship_kind() {
403 let text = emit(&sample(), &options());
404 let spec = text.split("model {").next().unwrap();
405 for line in [
406 " element component",
407 " element container",
408 " element process",
409 " element tests",
410 " tag external",
411 " tag tests",
412 " relationship implements {",
413 " relationship constructs {",
414 " relationship calls {",
415 " relationship names {",
416 " relationship uses {",
417 ] {
418 assert!(
419 spec.contains(&format!("{line}\n")),
420 "missing {line:?} in:\n{spec}"
421 );
422 }
423 }
424
425 #[test]
426 fn a_themed_kind_declares_its_color_and_styles_the_element_with_it() {
427 let opts = options().with_theme(BTreeMap::from([
428 ("container".to_string(), "#f0a884".to_string()),
429 ("process".to_string(), "#c9c9c9".to_string()),
430 ]));
431 let text = emit(&sample(), &opts);
432 let spec = text.split("model {").next().unwrap();
433 assert!(
434 spec.starts_with(&format!(
435 "{HEADER}\n\nspecification {{\n color theme_container #f0a884\n color theme_process #c9c9c9\n element component\n element container {{\n style {{\n color theme_container\n }}\n }}\n element process {{\n style {{\n color theme_process\n }}\n }}\n element tests\n tag external\n"
436 )),
437 "{spec}"
438 );
439 }
440
441 #[test]
442 fn a_theme_for_a_kind_the_model_lacks_emits_nothing_for_it() {
443 let opts = options().with_theme(BTreeMap::from([(
444 "browser".to_string(),
445 "#000000".to_string(),
446 )]));
447 let text = emit(&sample(), &opts);
448 assert!(!text.contains("browser"), "{text}");
449 assert_eq!(text, emit(&sample(), &options()));
450 }
451
452 #[test]
453 fn a_child_is_nested_inside_its_parent_under_its_last_segment() {
454 let text = emit(&sample(), &options());
455 let app_block = text.split(" app = container 'app' {").nth(1).unwrap();
456 assert!(
457 app_block.contains("\n server = component 'server' {"),
458 "{text}"
459 );
460 assert!(!text.contains("app.server = component"), "{text}");
461 }
462
463 #[test]
464 fn tags_come_first_in_a_body() {
465 let text = emit(&sample(), &options());
466 assert!(
467 text.contains("tests = tests 'tests' {\n #tests\n }"),
468 "{text}"
469 );
470 }
471
472 #[test]
473 fn a_quote_in_a_description_is_escaped_and_markdown_kept() {
474 let text = emit(&sample(), &options());
475 assert!(
476 text.contains("description 'Where it\\'s sent.\n\n- one\n- two'"),
477 "{text}"
478 );
479 }
480
481 #[test]
482 fn a_path_becomes_metadata_and_a_link_relative_to_the_output_directory() {
483 let text = emit(&sample(), &options());
484 assert!(text.contains("metadata {\n path 'crates/app/src/server.rs'\n }\n link ../../crates/app/src/server.rs\n"), "{text}");
485 }
486
487 #[test]
488 fn an_output_file_at_the_root_links_with_a_dot_prefix() {
489 let text = emit(&sample(), &EmitOptions::for_output_path("model.c4"));
490 assert!(text.contains("link ./crates/app\n"), "{text}");
491 assert_eq!(EmitOptions::for_output_path("./model.c4").link_prefix, "./");
492 }
493
494 #[test]
495 fn a_relation_line_carries_kind_label_and_technology() {
496 let text = emit(&sample(), &options());
497 assert!(
498 text.contains(" app.tests -[calls]-> app.server 'Server, connect'\n"),
499 "{text}"
500 );
501 assert!(
502 text.contains(
503 " app.server -[uses]-> node_driver 'spawns' {\n technology 'stdio'\n }\n"
504 ),
505 "{text}"
506 );
507 }
508
509 #[test]
510 fn a_relation_without_items_has_no_label() {
511 let mut model = sample();
512 model.relations[0].items.clear();
513 let text = emit(&model, &options());
514 assert!(
515 text.contains(" app.tests -[calls]-> app.server\n"),
516 "{text}"
517 );
518 }
519
520 #[test]
521 fn the_index_view_includes_every_top_level_element() {
522 let text = emit(&sample(), &options());
523 assert!(text.contains(" view index {\n title 'Overview'\n include app, node_driver\n autoLayout LeftRight\n }"), "{text}");
524 }
525
526 #[test]
527 fn an_element_with_children_gets_a_scoped_view_and_a_leaf_does_not() {
528 let text = emit(&sample(), &options());
529 assert!(text.contains(" view view_app of app {\n title 'app'\n include *\n autoLayout TopBottom\n }"), "{text}");
530 assert!(!text.contains("of app.server {"), "{text}");
531 assert!(!text.contains("of node_driver {"), "{text}");
532 }
533
534 #[test]
535 fn a_hyphen_in_an_id_is_an_underscore_everywhere_it_appears() {
536 let mut model = sample();
537 for e in &mut model.elements {
538 if e.id[0] == "app" {
539 e.id[0] = "my-app".into();
540 }
541 }
542 for r in &mut model.relations {
543 if r.source[0] == "app" {
544 r.source[0] = "my-app".into();
545 }
546 if r.target[0] == "app" {
547 r.target[0] = "my-app".into();
548 }
549 }
550 let text = emit(&model, &options());
551 assert!(text.contains(" my_app = container"), "{text}");
552 assert!(
553 text.contains("my_app.tests -[calls]-> my_app.server"),
554 "{text}"
555 );
556 assert!(text.contains("view view_my_app of my_app"), "{text}");
557 for line in text.lines().filter(|l| l.contains("my-app")) {
559 assert!(line.trim_start().starts_with("title "), "{line}");
560 }
561 }
562
563 #[test]
564 fn an_element_whose_parent_is_absent_is_emitted_at_the_top_level() {
565 let model = Model {
566 elements: vec![element("gone.child", ElementKind::Component)],
567 ..Default::default()
568 };
569 let text = emit(&model, &options());
570 assert!(
571 text.contains("model {\n child = component 'child'\n"),
572 "{text}"
573 );
574 }
575
576 #[test]
577 fn each_body_field_alone_earns_a_block() {
578 fn only(f: impl FnOnce(&mut Element)) -> Model {
579 let mut e = element("x", ElementKind::Container);
580 f(&mut e);
581 Model {
582 elements: vec![e],
583 ..Default::default()
584 }
585 }
586 let mut with_child = only(|_| {});
587 with_child
588 .elements
589 .push(element("x.y", ElementKind::Component));
590 let cases = [
591 ("tags", only(|e| e.tags = vec!["tests".into()])),
592 ("description", only(|e| e.description = Some("d".into()))),
593 ("technology", only(|e| e.technology = Some("t".into()))),
594 ("path", only(|e| e.path = Some("p".into()))),
595 ("child", with_child),
596 ];
597 for (field, model) in cases {
598 let text = emit(&model, &options());
599 assert!(
600 text.contains(" x = container 'x' {\n"),
601 "{field} alone should open a block:\n{text}"
602 );
603 }
604 }
605
606 #[test]
607 fn an_element_with_nothing_to_say_has_no_block() {
608 let model = Model {
609 elements: vec![element("leaf", ElementKind::Container)],
610 ..Default::default()
611 };
612 let text = emit(&model, &options());
613 assert!(text.contains(" leaf = container 'leaf'\n}"), "{text}");
614 }
615
616 #[test]
617 fn output_does_not_depend_on_input_order() {
618 let a = emit(&sample(), &options());
619 let mut shuffled = sample();
620 shuffled.elements.reverse();
621 shuffled.relations.reverse();
622 let b = emit(&shuffled, &options());
623 assert_eq!(a, b);
624 }
625
626 #[test]
627 fn output_has_no_carriage_returns_or_backslashes_in_paths() {
628 let mut model = sample();
629 model.elements[2].description = Some("line\r\nnext".into());
630 let text = emit(&model, &options());
631 assert!(!text.contains('\r'), "{text:?}");
632 assert!(text.contains("description 'line\nnext'"), "{text}");
633 }
634
635 #[test]
636 fn the_root_path_keeps_its_metadata_but_has_no_link() {
637 let mut model = sample();
638 model
639 .elements
640 .iter_mut()
641 .find(|e| e.id == ["app"])
642 .unwrap()
643 .path = Some(".".into());
644 let text = emit(&model, &options());
645 let app_block = text.split(" app = container 'app' {").nth(1).unwrap();
646 let head: String = app_block.lines().take(4).collect::<Vec<_>>().join("\n");
647 assert!(head.contains("path '.'"), "{head}");
648 assert!(!head.contains("link"), "{head}");
649 }
650
651 #[test]
652 fn a_space_or_quote_in_a_path_is_percent_encoded_in_the_link_only() {
653 let mut model = sample();
654 model
655 .elements
656 .iter_mut()
657 .find(|e| e.id == ["app", "server"])
658 .unwrap()
659 .path = Some("crates/my tool/it's.rs".into());
660 let text = emit(&model, &options());
661 assert!(text.contains("path 'crates/my tool/it\\'s.rs'"), "{text}");
662 assert!(
663 text.contains("link ../../crates/my%20tool/it%27s.rs\n"),
664 "{text}"
665 );
666 }
667
668 #[test]
669 fn link_encoding_keeps_unreserved_characters_and_slashes() {
670 assert_eq!(link_encode("a-b_c.d~/e"), "a-b_c.d~/e");
671 assert_eq!(link_encode("é"), "%C3%A9");
672 }
673
674 #[test]
675 fn two_ids_that_collapse_to_one_view_id_get_distinct_views() {
676 let model = Model {
677 elements: vec![
678 element("a.b_c", ElementKind::Container),
679 element("a.b_c.x", ElementKind::Component),
680 element("a_b.c", ElementKind::Container),
681 element("a_b.c.y", ElementKind::Component),
682 element("a_b_c", ElementKind::Container),
683 element("a_b_c.z", ElementKind::Component),
684 ],
685 ..Default::default()
686 };
687 let text = emit(&model, &options());
688 assert!(text.contains("view view_a_b_c of a.b_c {"), "{text}");
689 assert!(text.contains("view view_a_b_c_2 of a_b.c {"), "{text}");
690 assert!(text.contains("view view_a_b_c_3 of a_b_c {"), "{text}");
691 let mut normalized = model.clone();
692 normalized.normalize();
693 let names: Vec<String> = view_ids(&normalized).into_values().collect();
694 assert_eq!(names, ["view_a_b_c", "view_a_b_c_2", "view_a_b_c_3"]);
695 }
696
697 #[test]
698 fn view_ids_names_every_element_with_children_and_only_those() {
699 let mut model = sample();
700 model.normalize();
701 let names = view_ids(&model);
702 let keyed: Vec<(String, String)> = names
703 .iter()
704 .map(|(id, name)| (id.join("."), name.clone()))
705 .collect();
706 assert_eq!(keyed, [("app".to_string(), "view_app".to_string())]);
707 assert!(!names.contains_key(&id("app.server")));
708 assert!(!names.contains_key(&id("node_driver")));
709 }
710
711 #[test]
712 fn a_backslash_in_a_string_is_escaped() {
713 assert_eq!(quote("a\\b"), "'a\\\\b'");
714 }
715
716 #[test]
717 fn link_prefix_counts_directories_not_the_file() {
718 assert_eq!(
719 EmitOptions::for_output_path("a/b/c/m.c4").link_prefix,
720 "../../../"
721 );
722 assert_eq!(EmitOptions::for_output_path("a/m.c4").link_prefix, "../");
723 }
724
725 #[test]
726 fn a_relationship_kind_is_declared_with_its_line_and_head() {
727 let text = emit(&sample(), &options());
728 assert!(
729 text.contains(" relationship implements {\n line solid\n head onormal\n }\n"),
730 "{text}"
731 );
732 }
733}