1use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7use std::str::FromStr;
8
9use serde::Deserialize;
10use serde::de::DeserializeOwned;
11
12use crate::error::{Error, Result};
13
14pub const DEFAULT_OUTPUT_PATH: &str = "docs/architecture/model.c4";
16
17pub const FILE_NAME: &str = "asbuilt.toml";
19
20const OWN_TABLES: [&str; 4] = ["[output]", "[docs]", "[theme]", "[[externals]]"];
22
23#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
48pub struct Config {
49 #[serde(default)]
50 pub output: OutputConfig,
51 #[serde(default)]
52 pub docs: DocsConfig,
53 #[serde(default)]
58 pub theme: BTreeMap<String, ThemeColor>,
59 #[serde(default)]
60 pub externals: Vec<External>,
61 #[serde(flatten)]
62 pub sections: BTreeMap<String, toml::Value>,
63 #[serde(skip)]
66 pub path: Option<PathBuf>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct OutputConfig {
72 #[serde(default = "default_output_path")]
74 pub path: String,
75}
76
77fn default_output_path() -> String {
78 DEFAULT_OUTPUT_PATH.to_string()
79}
80
81impl Default for OutputConfig {
82 fn default() -> Self {
83 Self {
84 path: default_output_path(),
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
94#[serde(try_from = "toml::Value")]
95pub struct ThemeColor {
96 pub light: String,
97 pub dark: Option<String>,
98}
99
100impl TryFrom<toml::Value> for ThemeColor {
101 type Error = String;
102
103 fn try_from(value: toml::Value) -> std::result::Result<Self, String> {
104 let (light, dark) = match value {
105 toml::Value::String(light) => (light, None),
106 toml::Value::Table(table) => {
107 if let Some(key) = table
108 .keys()
109 .find(|k| !matches!(k.as_str(), "light" | "dark"))
110 {
111 return Err(format!(
112 "unknown key \"{key}\" in a theme color (expected light and optionally dark)"
113 ));
114 }
115 let text = |key: &str| match table.get(key) {
116 None => Ok(None),
117 Some(toml::Value::String(s)) => Ok(Some(s.clone())),
118 Some(other) => Err(format!(
119 "theme color {key} is a {}, not a \"#rrggbb\" string",
120 other.type_str()
121 )),
122 };
123 let light = text("light")?.ok_or_else(|| {
124 "a theme color table needs light (dark is optional)".to_string()
125 })?;
126 (light, text("dark")?)
127 }
128 other => {
129 return Err(format!(
130 "a theme color is \"#rrggbb\" or {{ light = \"#rrggbb\", dark = \"#rrggbb\" }}, not a {}",
131 other.type_str()
132 ));
133 }
134 };
135 for value in std::iter::once(&light).chain(dark.iter()) {
136 if !is_hex_color(value) {
137 return Err(format!(
138 "theme color \"{value}\" is not #rrggbb (six hex digits after #)"
139 ));
140 }
141 }
142 Ok(ThemeColor { light, dark })
143 }
144}
145
146pub fn is_hex_color(value: &str) -> bool {
148 value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit())
149}
150
151impl Config {
152 pub fn light_theme(&self) -> BTreeMap<String, String> {
154 self.theme
155 .iter()
156 .map(|(kind, color)| (kind.clone(), color.light.clone()))
157 .collect()
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
163#[serde(deny_unknown_fields)]
164pub struct DocsConfig {
165 #[serde(default)]
167 pub title: Option<String>,
168 #[serde(default)]
172 pub source_url: Option<String>,
173 #[serde(default)]
177 pub home_url: Option<String>,
178 #[serde(default)]
181 pub home_title: Option<String>,
182 #[serde(default)]
186 pub stylesheet: Option<String>,
187 #[serde(default)]
190 pub color_scheme: Option<ColorScheme>,
191 #[serde(default)]
194 pub scheme_toggle: Option<bool>,
195 #[serde(default)]
199 pub viewer: Option<bool>,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
205#[serde(rename_all = "lowercase")]
206pub enum ColorScheme {
207 #[default]
208 System,
209 Light,
210 Dark,
211}
212
213impl ColorScheme {
214 pub fn as_str(self) -> &'static str {
216 match self {
217 ColorScheme::System => "system",
218 ColorScheme::Light => "light",
219 ColorScheme::Dark => "dark",
220 }
221 }
222}
223
224impl FromStr for ColorScheme {
225 type Err = String;
226
227 fn from_str(text: &str) -> std::result::Result<Self, String> {
228 match text {
229 "system" => Ok(ColorScheme::System),
230 "light" => Ok(ColorScheme::Light),
231 "dark" => Ok(ColorScheme::Dark),
232 other => Err(format!(
233 "color scheme \"{other}\" is not system, light or dark"
234 )),
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
243#[serde(deny_unknown_fields)]
244pub struct External {
245 pub id: String,
247 pub kind: String,
249 pub title: String,
250 #[serde(default)]
251 pub technology: Option<String>,
252 #[serde(default)]
253 pub description: Option<String>,
254 #[serde(default)]
255 pub relations: Vec<ExternalRelation>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct ExternalRelation {
262 pub from: String,
264 pub title: String,
266 #[serde(default)]
267 pub technology: Option<String>,
268}
269
270impl FromStr for Config {
271 type Err = toml::de::Error;
272
273 fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
274 toml::from_str(text)
275 }
276}
277
278impl Config {
279 pub fn load(root: &Path) -> Result<Self> {
282 let path = root.join(FILE_NAME);
283 if !path.is_file() {
284 return Ok(Self::default());
285 }
286 Self::load_file(&path)
287 }
288
289 pub fn load_file(path: &Path) -> Result<Self> {
292 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
293 path: path.to_path_buf(),
294 source,
295 })?;
296 let mut config: Config = text.parse().map_err(|source| Error::Config {
297 path: path.to_path_buf(),
298 source,
299 })?;
300 config.path = Some(path.to_path_buf());
301 Ok(config)
302 }
303
304 pub fn file_path(&self) -> PathBuf {
307 self.path
308 .clone()
309 .unwrap_or_else(|| PathBuf::from(FILE_NAME))
310 }
311
312 pub fn check_tables(&self, frontends: &[&str]) -> Result<()> {
316 let Some((name, value)) = self
317 .sections
318 .iter()
319 .find(|(name, _)| !frontends.contains(&name.as_str()))
320 else {
321 return Ok(());
322 };
323 let entry = match value {
324 toml::Value::Table(_) => format!("[{name}]"),
325 toml::Value::Array(items)
326 if !items.is_empty() && items.iter().all(toml::Value::is_table) =>
327 {
328 format!("[[{name}]]")
329 }
330 _ => format!("{name} = ..."),
331 };
332 let known = OWN_TABLES
333 .iter()
334 .map(|t| t.to_string())
335 .chain(frontends.iter().map(|name| format!("[{name}]")))
336 .collect::<Vec<_>>()
337 .join(", ");
338 Err(Error::UnknownTopLevel {
339 path: self.file_path(),
340 entry,
341 known,
342 })
343 }
344
345 pub fn section<T: DeserializeOwned>(&self, name: &str) -> Result<Option<T>> {
349 let Some(value) = self.sections.get(name) else {
350 return Ok(None);
351 };
352 value
353 .clone()
354 .try_into()
355 .map(Some)
356 .map_err(|source| Error::Config {
357 path: self.file_path(),
358 source,
359 })
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 const BRIEF_EXAMPLE: &str = r#"
368[output]
369path = "docs/architecture/model.c4"
370
371[rust]
372extra_manifests = ["crates/site/Cargo.toml", "crates/site-e2e/Cargo.toml"]
373include_tests = true
374
375[[externals]]
376id = "node_driver"
377kind = "process"
378title = "Playwright driver"
379technology = "Node.js process"
380description = "The official Playwright server, assembled by build.rs."
381
382[[externals.relations]]
383from = "playwright_rs.server.playwright_server"
384title = "spawns"
385technology = "stdio"
386"#;
387
388 #[derive(Debug, PartialEq, Deserialize)]
389 #[serde(deny_unknown_fields)]
390 struct RustLike {
391 extra_manifests: Vec<String>,
392 include_tests: bool,
393 }
394
395 fn unknown(text: &str) -> (PathBuf, String, String) {
396 let config: Config = text.parse().unwrap();
397 match config.check_tables(&["rust"]) {
398 Err(Error::UnknownTopLevel { path, entry, known }) => (path, entry, known),
399 other => panic!("expected UnknownTopLevel, got {other:?}"),
400 }
401 }
402
403 #[test]
404 fn a_misspelled_table_is_named_as_written() {
405 assert_eq!(unknown("[rsut]\ninclude_tests = false\n").1, "[rsut]");
406 }
407
408 #[test]
409 fn a_singular_externals_table_is_named_as_an_array_of_tables() {
410 let (_, entry, _) =
411 unknown("[[external]]\nid = \"d\"\nkind = \"process\"\ntitle = \"D\"\n");
412 assert_eq!(entry, "[[external]]");
413 }
414
415 #[test]
416 fn a_stray_top_level_key_is_named_as_a_key() {
417 assert_eq!(unknown("title = \"My docs\"\n").1, "title = ...");
418 }
419
420 #[test]
421 fn a_stray_top_level_list_is_named_as_a_key() {
422 assert_eq!(unknown("tags = [\"a\"]\n").1, "tags = ...");
423 }
424
425 #[test]
426 fn a_stray_empty_list_is_named_as_a_key() {
427 assert_eq!(unknown("tags = []\n").1, "tags = ...");
428 }
429
430 #[test]
431 fn an_unknown_entry_lists_asbuilts_tables_then_the_front_ends() {
432 assert_eq!(
433 unknown("[rsut]\n").2,
434 "[output], [docs], [theme], [[externals]], [rust]"
435 );
436 }
437
438 #[test]
439 fn an_unknown_entry_in_a_defaulted_config_names_the_default_file() {
440 assert_eq!(unknown("[rsut]\n").0, PathBuf::from("asbuilt.toml"));
441 }
442
443 #[test]
444 fn an_unknown_entry_in_a_loaded_config_names_that_file() {
445 let dir = tempfile::tempdir().unwrap();
446 let path = dir.path().join("elsewhere.toml");
447 std::fs::write(&path, "[rsut]\n").unwrap();
448 let config = Config::load_file(&path).unwrap();
449 assert!(matches!(
450 config.check_tables(&["rust"]),
451 Err(Error::UnknownTopLevel { path: named, .. }) if named == path
452 ));
453 }
454
455 #[test]
456 fn a_front_ends_table_and_asbuilts_own_pass_the_check() {
457 let config: Config = BRIEF_EXAMPLE.parse().unwrap();
458 assert!(config.check_tables(&["rust"]).is_ok());
459 }
460
461 #[test]
462 fn the_brief_example_parses_to_its_values() {
463 let config: Config = BRIEF_EXAMPLE.parse().unwrap();
464 assert_eq!(config.output.path, "docs/architecture/model.c4");
465 assert_eq!(config.externals.len(), 1);
466 let ext = &config.externals[0];
467 assert_eq!(ext.id, "node_driver");
468 assert_eq!(ext.kind, "process");
469 assert_eq!(ext.technology.as_deref(), Some("Node.js process"));
470 assert_eq!(
471 ext.relations,
472 vec![ExternalRelation {
473 from: "playwright_rs.server.playwright_server".into(),
474 title: "spawns".into(),
475 technology: Some("stdio".into()),
476 }]
477 );
478 }
479
480 #[test]
481 fn a_front_end_table_round_trips_through_its_own_schema() {
482 let config: Config = BRIEF_EXAMPLE.parse().unwrap();
483 let rust: Option<RustLike> = config.section("rust").unwrap();
484 assert_eq!(
485 rust,
486 Some(RustLike {
487 extra_manifests: vec![
488 "crates/site/Cargo.toml".into(),
489 "crates/site-e2e/Cargo.toml".into()
490 ],
491 include_tests: true,
492 })
493 );
494 }
495
496 #[test]
497 fn an_absent_section_is_none() {
498 let config: Config = "".parse().unwrap();
499 let rust: Option<RustLike> = config.section("rust").unwrap();
500 assert_eq!(rust, None);
501 }
502
503 #[test]
504 fn a_section_that_does_not_fit_its_schema_is_a_config_error_naming_the_file() {
505 let mut config: Config = "[rust]\ninclude_test = true\n".parse().unwrap();
506 config.path = Some(PathBuf::from("here/asbuilt.toml"));
507 match config.section::<RustLike>("rust") {
508 Err(Error::Config { path, .. }) => assert_eq!(path, PathBuf::from("here/asbuilt.toml")),
509 other => panic!("expected Config error, got {other:?}"),
510 }
511 }
512
513 #[test]
514 fn an_empty_config_has_the_default_output_path() {
515 let config: Config = "".parse().unwrap();
516 assert_eq!(config.output.path, DEFAULT_OUTPUT_PATH);
517 }
518
519 #[test]
520 fn an_unknown_key_under_output_is_rejected() {
521 let result: std::result::Result<Config, _> = "[output]\npth = \"x\"\n".parse();
522 assert!(result.is_err(), "got {result:?}");
523 }
524
525 #[test]
526 fn an_unknown_key_in_an_external_is_rejected() {
527 let result: std::result::Result<Config, _> =
528 "[[externals]]\nid = \"x\"\nkind = \"k\"\ntitle = \"t\"\nlabel = \"no\"\n".parse();
529 assert!(result.is_err(), "got {result:?}");
530 }
531
532 #[test]
533 fn the_docs_table_parses_and_defaults_to_nothing() {
534 let config: Config = "[docs]\ntitle = \"asbuilt\"\nsource_url = \"https://x/blob/main/\"\nhome_url = \"../\"\nhome_title = \"Home\"\nstylesheet = \"../site.css\"\n"
535 .parse()
536 .unwrap();
537 assert_eq!(config.docs.title.as_deref(), Some("asbuilt"));
538 assert_eq!(
539 config.docs.source_url.as_deref(),
540 Some("https://x/blob/main/")
541 );
542 assert_eq!(config.docs.home_url.as_deref(), Some("../"));
543 assert_eq!(config.docs.home_title.as_deref(), Some("Home"));
544 assert_eq!(config.docs.stylesheet.as_deref(), Some("../site.css"));
545 let empty: Config = "".parse().unwrap();
546 assert_eq!(empty.docs, DocsConfig::default());
547 }
548
549 #[test]
550 fn a_theme_color_is_a_string_or_a_light_dark_pair() {
551 let config: Config =
552 "[theme]\ncontainer = \"#3b82f6\"\nprocess = { light = \"#c9c9c9\", dark = \"#444444\" }\n"
553 .parse()
554 .unwrap();
555 assert_eq!(
556 config.theme["container"],
557 ThemeColor {
558 light: "#3b82f6".into(),
559 dark: None
560 }
561 );
562 assert_eq!(
563 config.theme["process"],
564 ThemeColor {
565 light: "#c9c9c9".into(),
566 dark: Some("#444444".into())
567 }
568 );
569 assert_eq!(
570 config.light_theme(),
571 BTreeMap::from([
572 ("container".to_string(), "#3b82f6".to_string()),
573 ("process".to_string(), "#c9c9c9".to_string()),
574 ])
575 );
576 }
577
578 #[test]
579 fn a_theme_color_that_is_not_six_hex_digits_is_rejected_naming_the_value() {
580 for bad in ["blue", "#fff", "#12345g", "3b82f6"] {
581 let result: std::result::Result<Config, _> =
582 format!("[theme]\ncontainer = \"{bad}\"\n").parse();
583 let err = result.err().map(|e| e.to_string()).unwrap_or_default();
584 assert!(err.contains(bad), "{bad}: {err}");
585 }
586 let result: std::result::Result<Config, _> =
587 "[theme]\ncontainer = { light = \"#3b82f6\", dark = \"nope\" }\n".parse();
588 assert!(result.is_err(), "{result:?}");
589 }
590
591 fn theme_error(color: &str) -> String {
592 format!("[theme]\ncontainer = {color}\n")
593 .parse::<Config>()
594 .err()
595 .map(|e| e.to_string())
596 .unwrap_or_default()
597 }
598
599 #[test]
600 fn an_unknown_key_in_a_theme_color_is_rejected_naming_it() {
601 let err = theme_error("{ light = \"#f0a884\", drak = \"#b5673f\" }");
602 assert!(err.contains("unknown key \"drak\""), "{err}");
603 }
604
605 #[test]
606 fn a_theme_color_table_without_light_is_rejected_saying_so() {
607 let err = theme_error("{ dark = \"#b5673f\" }");
608 assert!(err.contains("needs light"), "{err}");
609 }
610
611 #[test]
612 fn a_theme_color_of_another_type_is_rejected_naming_the_type() {
613 assert!(
614 theme_error("3").contains("not a integer"),
615 "{}",
616 theme_error("3")
617 );
618 let err = theme_error("{ light = 3 }");
619 assert!(err.contains("light is a integer"), "{err}");
620 }
621
622 #[test]
623 fn the_docs_viewer_key_parses_and_defaults_to_nothing() {
624 let config: Config = "[docs]\nviewer = false\n".parse().unwrap();
625 assert_eq!(config.docs.viewer, Some(false));
626 let empty: Config = "[docs]\n".parse().unwrap();
627 assert_eq!(empty.docs.viewer, None);
628 }
629
630 #[test]
631 fn the_docs_color_scheme_and_toggle_parse_and_default_to_nothing() {
632 let config: Config = "[docs]\ncolor_scheme = \"dark\"\nscheme_toggle = false\n"
633 .parse()
634 .unwrap();
635 assert_eq!(config.docs.color_scheme, Some(ColorScheme::Dark));
636 assert_eq!(config.docs.scheme_toggle, Some(false));
637 let empty: Config = "[docs]\n".parse().unwrap();
638 assert_eq!(empty.docs.color_scheme, None);
639 assert_eq!(empty.docs.scheme_toggle, None);
640 }
641
642 #[test]
643 fn a_color_scheme_that_is_not_one_of_the_three_is_rejected_naming_them() {
644 let err = "[docs]\ncolor_scheme = \"sepia\"\n"
645 .parse::<Config>()
646 .unwrap_err()
647 .to_string();
648 assert!(err.contains("sepia") && err.contains("dark"), "{err}");
649 }
650
651 #[test]
652 fn a_color_scheme_reads_from_its_name_and_prints_back_the_same() {
653 for scheme in [ColorScheme::System, ColorScheme::Light, ColorScheme::Dark] {
654 assert_eq!(scheme.as_str().parse::<ColorScheme>(), Ok(scheme));
655 }
656 assert_eq!(
657 "sepia".parse::<ColorScheme>(),
658 Err("color scheme \"sepia\" is not system, light or dark".to_string())
659 );
660 }
661
662 #[test]
663 fn an_unknown_key_under_docs_is_rejected() {
664 let result: std::result::Result<Config, _> = "[docs]\ntitel = \"x\"\n".parse();
665 assert!(result.is_err(), "got {result:?}");
666 }
667
668 #[test]
669 fn an_external_without_relations_has_none() {
670 let config: Config = "[[externals]]\nid = \"x\"\nkind = \"k\"\ntitle = \"t\"\n"
671 .parse()
672 .unwrap();
673 assert!(config.externals[0].relations.is_empty());
674 }
675
676 #[test]
677 fn a_missing_file_at_the_root_loads_the_defaults() {
678 let dir = tempfile::tempdir().unwrap();
679 let config = Config::load(dir.path()).unwrap();
680 assert_eq!(config, Config::default());
681 }
682
683 #[test]
684 fn a_file_at_the_root_is_loaded_and_remembers_its_path() {
685 let dir = tempfile::tempdir().unwrap();
686 let path = dir.path().join(FILE_NAME);
687 std::fs::write(&path, "[output]\npath = \"m.c4\"\n").unwrap();
688 let config = Config::load(dir.path()).unwrap();
689 assert_eq!(config.output.path, "m.c4");
690 assert_eq!(config.path, Some(path));
691 }
692
693 #[test]
694 fn a_malformed_file_is_a_config_error_naming_the_file() {
695 let dir = tempfile::tempdir().unwrap();
696 let path = dir.path().join(FILE_NAME);
697 std::fs::write(&path, "[output\n").unwrap();
698 match Config::load(dir.path()) {
699 Err(Error::Config { path: p, .. }) => assert_eq!(p, path),
700 other => panic!("expected Config error, got {other:?}"),
701 }
702 }
703
704 #[test]
705 fn an_explicit_path_that_does_not_exist_is_an_io_error_naming_it() {
706 let missing = PathBuf::from("/no/such/asbuilt.toml");
707 match Config::load_file(&missing) {
708 Err(Error::Io { path, .. }) => assert_eq!(path, missing),
709 other => panic!("expected Io error, got {other:?}"),
710 }
711 }
712}