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
20#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
44pub struct Config {
45 #[serde(default)]
46 pub output: OutputConfig,
47 #[serde(default)]
48 pub docs: DocsConfig,
49 #[serde(default)]
53 pub theme: BTreeMap<String, ThemeColor>,
54 #[serde(default)]
55 pub externals: Vec<External>,
56 #[serde(flatten)]
57 pub sections: BTreeMap<String, toml::Value>,
58 #[serde(skip)]
61 pub path: Option<PathBuf>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct OutputConfig {
67 #[serde(default = "default_output_path")]
69 pub path: String,
70}
71
72fn default_output_path() -> String {
73 DEFAULT_OUTPUT_PATH.to_string()
74}
75
76impl Default for OutputConfig {
77 fn default() -> Self {
78 Self {
79 path: default_output_path(),
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
89#[serde(try_from = "toml::Value")]
90pub struct ThemeColor {
91 pub light: String,
92 pub dark: Option<String>,
93}
94
95impl TryFrom<toml::Value> for ThemeColor {
96 type Error = String;
97
98 fn try_from(value: toml::Value) -> std::result::Result<Self, String> {
99 let (light, dark) = match value {
100 toml::Value::String(light) => (light, None),
101 toml::Value::Table(table) => {
102 if let Some(key) = table
103 .keys()
104 .find(|k| !matches!(k.as_str(), "light" | "dark"))
105 {
106 return Err(format!(
107 "unknown key \"{key}\" in a theme color (expected light and optionally dark)"
108 ));
109 }
110 let text = |key: &str| match table.get(key) {
111 None => Ok(None),
112 Some(toml::Value::String(s)) => Ok(Some(s.clone())),
113 Some(other) => Err(format!(
114 "theme color {key} is a {}, not a \"#rrggbb\" string",
115 other.type_str()
116 )),
117 };
118 let light = text("light")?.ok_or_else(|| {
119 "a theme color table needs light (dark is optional)".to_string()
120 })?;
121 (light, text("dark")?)
122 }
123 other => {
124 return Err(format!(
125 "a theme color is \"#rrggbb\" or {{ light = \"#rrggbb\", dark = \"#rrggbb\" }}, not a {}",
126 other.type_str()
127 ));
128 }
129 };
130 for value in std::iter::once(&light).chain(dark.iter()) {
131 if !is_hex_color(value) {
132 return Err(format!(
133 "theme color \"{value}\" is not #rrggbb (six hex digits after #)"
134 ));
135 }
136 }
137 Ok(ThemeColor { light, dark })
138 }
139}
140
141pub fn is_hex_color(value: &str) -> bool {
143 value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit())
144}
145
146impl Config {
147 pub fn light_theme(&self) -> BTreeMap<String, String> {
149 self.theme
150 .iter()
151 .map(|(kind, color)| (kind.clone(), color.light.clone()))
152 .collect()
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
158#[serde(deny_unknown_fields)]
159pub struct DocsConfig {
160 #[serde(default)]
162 pub title: Option<String>,
163 #[serde(default)]
167 pub source_url: Option<String>,
168 #[serde(default)]
172 pub home_url: Option<String>,
173 #[serde(default)]
175 pub home_title: Option<String>,
176 #[serde(default)]
180 pub stylesheet: Option<String>,
181 #[serde(default)]
184 pub color_scheme: Option<ColorScheme>,
185 #[serde(default)]
188 pub scheme_toggle: Option<bool>,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
194#[serde(rename_all = "lowercase")]
195pub enum ColorScheme {
196 #[default]
197 System,
198 Light,
199 Dark,
200}
201
202impl ColorScheme {
203 pub fn as_str(self) -> &'static str {
205 match self {
206 ColorScheme::System => "system",
207 ColorScheme::Light => "light",
208 ColorScheme::Dark => "dark",
209 }
210 }
211}
212
213impl FromStr for ColorScheme {
214 type Err = String;
215
216 fn from_str(text: &str) -> std::result::Result<Self, String> {
217 match text {
218 "system" => Ok(ColorScheme::System),
219 "light" => Ok(ColorScheme::Light),
220 "dark" => Ok(ColorScheme::Dark),
221 other => Err(format!(
222 "color scheme \"{other}\" is not system, light or dark"
223 )),
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct External {
234 pub id: String,
236 pub kind: String,
238 pub title: String,
239 #[serde(default)]
240 pub technology: Option<String>,
241 #[serde(default)]
242 pub description: Option<String>,
243 #[serde(default)]
244 pub relations: Vec<ExternalRelation>,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct ExternalRelation {
251 pub from: String,
253 pub title: String,
255 #[serde(default)]
256 pub technology: Option<String>,
257}
258
259impl FromStr for Config {
260 type Err = toml::de::Error;
261
262 fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
263 toml::from_str(text)
264 }
265}
266
267impl Config {
268 pub fn load(root: &Path) -> Result<Self> {
271 let path = root.join(FILE_NAME);
272 if !path.is_file() {
273 return Ok(Self::default());
274 }
275 Self::load_file(&path)
276 }
277
278 pub fn load_file(path: &Path) -> Result<Self> {
281 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
282 path: path.to_path_buf(),
283 source,
284 })?;
285 let mut config: Config = text.parse().map_err(|source| Error::Config {
286 path: path.to_path_buf(),
287 source,
288 })?;
289 config.path = Some(path.to_path_buf());
290 Ok(config)
291 }
292
293 pub fn section<T: DeserializeOwned>(&self, name: &str) -> Result<Option<T>> {
297 let Some(value) = self.sections.get(name) else {
298 return Ok(None);
299 };
300 value
301 .clone()
302 .try_into()
303 .map(Some)
304 .map_err(|source| Error::Config {
305 path: self
306 .path
307 .clone()
308 .unwrap_or_else(|| PathBuf::from(FILE_NAME)),
309 source,
310 })
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 const BRIEF_EXAMPLE: &str = r#"
319[output]
320path = "docs/architecture/model.c4"
321
322[rust]
323extra_manifests = ["crates/site/Cargo.toml", "crates/site-e2e/Cargo.toml"]
324include_tests = true
325
326[[externals]]
327id = "node_driver"
328kind = "process"
329title = "Playwright driver"
330technology = "Node.js process"
331description = "The official Playwright server, assembled by build.rs."
332
333[[externals.relations]]
334from = "playwright_rs.server.playwright_server"
335title = "spawns"
336technology = "stdio"
337"#;
338
339 #[derive(Debug, PartialEq, Deserialize)]
340 #[serde(deny_unknown_fields)]
341 struct RustLike {
342 extra_manifests: Vec<String>,
343 include_tests: bool,
344 }
345
346 #[test]
347 fn the_brief_example_parses_to_its_values() {
348 let config: Config = BRIEF_EXAMPLE.parse().unwrap();
349 assert_eq!(config.output.path, "docs/architecture/model.c4");
350 assert_eq!(config.externals.len(), 1);
351 let ext = &config.externals[0];
352 assert_eq!(ext.id, "node_driver");
353 assert_eq!(ext.kind, "process");
354 assert_eq!(ext.technology.as_deref(), Some("Node.js process"));
355 assert_eq!(
356 ext.relations,
357 vec![ExternalRelation {
358 from: "playwright_rs.server.playwright_server".into(),
359 title: "spawns".into(),
360 technology: Some("stdio".into()),
361 }]
362 );
363 }
364
365 #[test]
366 fn a_front_end_table_round_trips_through_its_own_schema() {
367 let config: Config = BRIEF_EXAMPLE.parse().unwrap();
368 let rust: Option<RustLike> = config.section("rust").unwrap();
369 assert_eq!(
370 rust,
371 Some(RustLike {
372 extra_manifests: vec![
373 "crates/site/Cargo.toml".into(),
374 "crates/site-e2e/Cargo.toml".into()
375 ],
376 include_tests: true,
377 })
378 );
379 }
380
381 #[test]
382 fn an_absent_section_is_none() {
383 let config: Config = "".parse().unwrap();
384 let rust: Option<RustLike> = config.section("rust").unwrap();
385 assert_eq!(rust, None);
386 }
387
388 #[test]
389 fn a_section_that_does_not_fit_its_schema_is_a_config_error_naming_the_file() {
390 let mut config: Config = "[rust]\ninclude_test = true\n".parse().unwrap();
391 config.path = Some(PathBuf::from("here/asbuilt.toml"));
392 match config.section::<RustLike>("rust") {
393 Err(Error::Config { path, .. }) => assert_eq!(path, PathBuf::from("here/asbuilt.toml")),
394 other => panic!("expected Config error, got {other:?}"),
395 }
396 }
397
398 #[test]
399 fn an_empty_config_has_the_default_output_path() {
400 let config: Config = "".parse().unwrap();
401 assert_eq!(config.output.path, DEFAULT_OUTPUT_PATH);
402 }
403
404 #[test]
405 fn an_unknown_key_under_output_is_rejected() {
406 let result: std::result::Result<Config, _> = "[output]\npth = \"x\"\n".parse();
407 assert!(result.is_err(), "got {result:?}");
408 }
409
410 #[test]
411 fn an_unknown_key_in_an_external_is_rejected() {
412 let result: std::result::Result<Config, _> =
413 "[[externals]]\nid = \"x\"\nkind = \"k\"\ntitle = \"t\"\nlabel = \"no\"\n".parse();
414 assert!(result.is_err(), "got {result:?}");
415 }
416
417 #[test]
418 fn the_docs_table_parses_and_defaults_to_nothing() {
419 let config: Config = "[docs]\ntitle = \"asbuilt\"\nsource_url = \"https://x/blob/main/\"\nhome_url = \"../\"\nhome_title = \"Home\"\nstylesheet = \"../site.css\"\n"
420 .parse()
421 .unwrap();
422 assert_eq!(config.docs.title.as_deref(), Some("asbuilt"));
423 assert_eq!(
424 config.docs.source_url.as_deref(),
425 Some("https://x/blob/main/")
426 );
427 assert_eq!(config.docs.home_url.as_deref(), Some("../"));
428 assert_eq!(config.docs.home_title.as_deref(), Some("Home"));
429 assert_eq!(config.docs.stylesheet.as_deref(), Some("../site.css"));
430 let empty: Config = "".parse().unwrap();
431 assert_eq!(empty.docs, DocsConfig::default());
432 }
433
434 #[test]
435 fn a_theme_color_is_a_string_or_a_light_dark_pair() {
436 let config: Config =
437 "[theme]\ncontainer = \"#3b82f6\"\nprocess = { light = \"#c9c9c9\", dark = \"#444444\" }\n"
438 .parse()
439 .unwrap();
440 assert_eq!(
441 config.theme["container"],
442 ThemeColor {
443 light: "#3b82f6".into(),
444 dark: None
445 }
446 );
447 assert_eq!(
448 config.theme["process"],
449 ThemeColor {
450 light: "#c9c9c9".into(),
451 dark: Some("#444444".into())
452 }
453 );
454 assert_eq!(
455 config.light_theme(),
456 BTreeMap::from([
457 ("container".to_string(), "#3b82f6".to_string()),
458 ("process".to_string(), "#c9c9c9".to_string()),
459 ])
460 );
461 }
462
463 #[test]
464 fn a_theme_color_that_is_not_six_hex_digits_is_rejected_naming_the_value() {
465 for bad in ["blue", "#fff", "#12345g", "3b82f6"] {
466 let result: std::result::Result<Config, _> =
467 format!("[theme]\ncontainer = \"{bad}\"\n").parse();
468 let err = result.err().map(|e| e.to_string()).unwrap_or_default();
469 assert!(err.contains(bad), "{bad}: {err}");
470 }
471 let result: std::result::Result<Config, _> =
472 "[theme]\ncontainer = { light = \"#3b82f6\", dark = \"nope\" }\n".parse();
473 assert!(result.is_err(), "{result:?}");
474 }
475
476 fn theme_error(color: &str) -> String {
477 format!("[theme]\ncontainer = {color}\n")
478 .parse::<Config>()
479 .err()
480 .map(|e| e.to_string())
481 .unwrap_or_default()
482 }
483
484 #[test]
485 fn an_unknown_key_in_a_theme_color_is_rejected_naming_it() {
486 let err = theme_error("{ light = \"#f0a884\", drak = \"#b5673f\" }");
487 assert!(err.contains("unknown key \"drak\""), "{err}");
488 }
489
490 #[test]
491 fn a_theme_color_table_without_light_is_rejected_saying_so() {
492 let err = theme_error("{ dark = \"#b5673f\" }");
493 assert!(err.contains("needs light"), "{err}");
494 }
495
496 #[test]
497 fn a_theme_color_of_another_type_is_rejected_naming_the_type() {
498 assert!(
499 theme_error("3").contains("not a integer"),
500 "{}",
501 theme_error("3")
502 );
503 let err = theme_error("{ light = 3 }");
504 assert!(err.contains("light is a integer"), "{err}");
505 }
506
507 #[test]
508 fn the_docs_color_scheme_and_toggle_parse_and_default_to_nothing() {
509 let config: Config = "[docs]\ncolor_scheme = \"dark\"\nscheme_toggle = false\n"
510 .parse()
511 .unwrap();
512 assert_eq!(config.docs.color_scheme, Some(ColorScheme::Dark));
513 assert_eq!(config.docs.scheme_toggle, Some(false));
514 let empty: Config = "[docs]\n".parse().unwrap();
515 assert_eq!(empty.docs.color_scheme, None);
516 assert_eq!(empty.docs.scheme_toggle, None);
517 }
518
519 #[test]
520 fn a_color_scheme_that_is_not_one_of_the_three_is_rejected_naming_them() {
521 let err = "[docs]\ncolor_scheme = \"sepia\"\n"
522 .parse::<Config>()
523 .unwrap_err()
524 .to_string();
525 assert!(err.contains("sepia") && err.contains("dark"), "{err}");
526 }
527
528 #[test]
529 fn a_color_scheme_reads_from_its_name_and_prints_back_the_same() {
530 for scheme in [ColorScheme::System, ColorScheme::Light, ColorScheme::Dark] {
531 assert_eq!(scheme.as_str().parse::<ColorScheme>(), Ok(scheme));
532 }
533 assert_eq!(
534 "sepia".parse::<ColorScheme>(),
535 Err("color scheme \"sepia\" is not system, light or dark".to_string())
536 );
537 }
538
539 #[test]
540 fn an_unknown_key_under_docs_is_rejected() {
541 let result: std::result::Result<Config, _> = "[docs]\ntitel = \"x\"\n".parse();
542 assert!(result.is_err(), "got {result:?}");
543 }
544
545 #[test]
546 fn an_external_without_relations_has_none() {
547 let config: Config = "[[externals]]\nid = \"x\"\nkind = \"k\"\ntitle = \"t\"\n"
548 .parse()
549 .unwrap();
550 assert!(config.externals[0].relations.is_empty());
551 }
552
553 #[test]
554 fn a_missing_file_at_the_root_loads_the_defaults() {
555 let dir = tempfile::tempdir().unwrap();
556 let config = Config::load(dir.path()).unwrap();
557 assert_eq!(config, Config::default());
558 }
559
560 #[test]
561 fn a_file_at_the_root_is_loaded_and_remembers_its_path() {
562 let dir = tempfile::tempdir().unwrap();
563 let path = dir.path().join(FILE_NAME);
564 std::fs::write(&path, "[output]\npath = \"m.c4\"\n").unwrap();
565 let config = Config::load(dir.path()).unwrap();
566 assert_eq!(config.output.path, "m.c4");
567 assert_eq!(config.path, Some(path));
568 }
569
570 #[test]
571 fn a_malformed_file_is_a_config_error_naming_the_file() {
572 let dir = tempfile::tempdir().unwrap();
573 let path = dir.path().join(FILE_NAME);
574 std::fs::write(&path, "[output\n").unwrap();
575 match Config::load(dir.path()) {
576 Err(Error::Config { path: p, .. }) => assert_eq!(p, path),
577 other => panic!("expected Config error, got {other:?}"),
578 }
579 }
580
581 #[test]
582 fn an_explicit_path_that_does_not_exist_is_an_io_error_naming_it() {
583 let missing = PathBuf::from("/no/such/asbuilt.toml");
584 match Config::load_file(&missing) {
585 Err(Error::Io { path, .. }) => assert_eq!(path, missing),
586 other => panic!("expected Io error, got {other:?}"),
587 }
588 }
589}