Skip to main content

asbuilt_core/
config.rs

1//! `asbuilt.toml`: the output path, the externals nothing static in the
2//! code can state, and every front-end's own table kept raw for it to
3//! parse.
4
5use 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
14/// Where `survey` writes and `check` reads when the config says nothing.
15pub const DEFAULT_OUTPUT_PATH: &str = "docs/architecture/model.c4";
16
17/// The config file name, looked up at the surveyed root.
18pub const FILE_NAME: &str = "asbuilt.toml";
19
20/// The parsed `asbuilt.toml`.
21///
22/// Unknown top-level tables are not an error: they are a front-end's
23/// (`[rust]`, say) and stay in `sections` for it to deserialize with its
24/// own schema. Unknown keys *inside* `[output]` or an `[[externals]]`
25/// entry are rejected.
26///
27/// ```
28/// use asbuilt_core::Config;
29///
30/// let config: Config = r#"
31/// [output]
32/// path = "arch/model.c4"
33///
34/// [rust]
35/// include_tests = false
36/// "#
37/// .parse()
38/// .unwrap();
39///
40/// assert_eq!(config.output.path, "arch/model.c4");
41/// assert!(config.sections.contains_key("rust"));
42/// ```
43#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
44pub struct Config {
45    #[serde(default)]
46    pub output: OutputConfig,
47    #[serde(default)]
48    pub docs: DocsConfig,
49    /// `[theme]`: a color per element kind (`container`, `component`, or
50    /// an external kind), emitted as LikeC4 element styles so the
51    /// diagrams come out in the consumer's palette.
52    #[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    /// Where this config was read from, for error messages. `None` for a
59    /// config parsed from a string or defaulted.
60    #[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    /// Relative to the surveyed root.
68    #[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/// A kind's color: one value for every scheme, or a light and a dark
85/// one. Written as `"#3b82f6"` or `{ light = "#3b82f6", dark = "#1e40af" }`.
86/// Parsed by hand rather than as an untagged enum, whose only error is
87/// "did not match any variant": a mistake here names the key or value.
88#[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
141/// `#rrggbb`, six hex digits after the `#`.
142pub 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    /// The light color per themed kind, the emitter's input.
148    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/// What `asbuilt docs` cannot read off the code.
157#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
158#[serde(deny_unknown_fields)]
159pub struct DocsConfig {
160    /// The site title; the root directory's name when absent.
161    #[serde(default)]
162    pub title: Option<String>,
163    /// A URL prefix that turns a repo-relative path into a link, e.g.
164    /// `https://github.com/owner/repo/blob/main/`. Paths stay plain text
165    /// when absent.
166    #[serde(default)]
167    pub source_url: Option<String>,
168    /// A link back to the site that hosts the tree, first in every
169    /// page's header trail; relative values (`../`) resolve
170    /// from each page's depth. No link when absent.
171    #[serde(default)]
172    pub home_url: Option<String>,
173    /// The text of that link; the URL itself when absent.
174    #[serde(default)]
175    pub home_title: Option<String>,
176    /// A stylesheet linked last in every page, after the tree's own, so
177    /// the host can restate the page tokens in its palette. Resolved
178    /// like `home_url`.
179    #[serde(default)]
180    pub stylesheet: Option<String>,
181    /// The scheme the pages show before a visitor chooses one; the
182    /// system's setting when absent.
183    #[serde(default)]
184    pub color_scheme: Option<ColorScheme>,
185    /// Whether every page carries a System / Light / Dark control, and
186    /// with it the tree's one script. On when absent.
187    #[serde(default)]
188    pub scheme_toggle: Option<bool>,
189}
190
191/// The color scheme the documentation pages show before a visitor
192/// chooses one: the system's setting, or light or dark regardless of it.
193#[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    /// The name as `asbuilt.toml` and the `--color-scheme` flag spell it.
204    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/// Something outside the code that a module talks to: a spawned
229/// process, a browser, a service. Nothing static says it exists, so the
230/// config does.
231#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct External {
234    /// A LikeC4 identifier; must not collide with a generated id.
235    pub id: String,
236    /// The LikeC4 element kind (`process`, `browser`, ...).
237    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/// An edge from a generated element (or another external) to this one.
248#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct ExternalRelation {
251    /// A dotted id that must name a surveyed element or a sibling external.
252    pub from: String,
253    /// The label.
254    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    /// The config at `root/asbuilt.toml`, or the defaults when there is
269    /// no such file.
270    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    /// A config from an explicit path (`--config`); a missing file is an
279    /// error here, unlike [`Config::load`].
280    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    /// A front-end's own table, deserialized with its schema. `Ok(None)`
294    /// when the table is absent; a `Config` error naming the file when
295    /// it is present and does not fit.
296    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}