Skip to main content

asbuilt_core/
config.rs

1//! `asbuilt.toml`: the release it is written for, the output path, the
2//! externals nothing static in the code can state, and every front-end's
3//! own table kept raw for it to parse.
4
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7use std::str::FromStr;
8
9use serde::de::{DeserializeOwned, Error as _};
10use serde::{Deserialize, Deserializer};
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 top-level key that pins the asbuilt release a config is written
21/// for: `asbuilt = "0.3.0"`.
22pub const PIN_KEY: &str = "asbuilt";
23
24/// asbuilt's own top-level tables, as the file writes them.
25const OWN_TABLES: [&str; 4] = ["[output]", "[docs]", "[theme]", "[[externals]]"];
26
27/// The parsed `asbuilt.toml`.
28///
29/// Other top-level entries stay in `sections`, where a front-end's table
30/// (`[rust]`, say) waits to be deserialized with that front-end's schema;
31/// [`Config::check_tables`] rejects any that names no front-end. Unknown
32/// keys *inside* `[output]` or an `[[externals]]` entry are rejected
33/// when parsing.
34///
35/// ```
36/// use asbuilt_core::Config;
37///
38/// let config: Config = r#"
39/// [output]
40/// path = "arch/model.c4"
41///
42/// [rust]
43/// include_tests = false
44/// "#
45/// .parse()
46/// .unwrap();
47///
48/// assert_eq!(config.output.path, "arch/model.c4");
49/// assert!(config.sections.contains_key("rust"));
50/// ```
51#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
52pub struct Config {
53    /// The asbuilt release this config and its model are written for,
54    /// as `cargo install --version` takes it. [`Config::load_for`] checks
55    /// it against the running release before reading anything else;
56    /// `None` runs under any release.
57    #[serde(default, deserialize_with = "release_pin")]
58    pub asbuilt: Option<String>,
59    #[serde(default)]
60    pub output: OutputConfig,
61    #[serde(default)]
62    pub docs: DocsConfig,
63    /// `[theme]`: a color per element kind (`container`, `component`,
64    /// `bin`, `tests`, `examples`, or an external kind), emitted as
65    /// LikeC4 element styles so the
66    /// diagrams come out in the consumer's palette.
67    #[serde(default)]
68    pub theme: BTreeMap<String, ThemeColor>,
69    #[serde(default)]
70    pub externals: Vec<External>,
71    #[serde(flatten)]
72    pub sections: BTreeMap<String, toml::Value>,
73    /// Where this config was read from, for error messages. `None` for a
74    /// config parsed from a string or defaulted.
75    #[serde(skip)]
76    pub path: Option<PathBuf>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct OutputConfig {
82    /// Relative to the surveyed root.
83    #[serde(default = "default_output_path")]
84    pub path: String,
85}
86
87fn default_output_path() -> String {
88    DEFAULT_OUTPUT_PATH.to_string()
89}
90
91impl Default for OutputConfig {
92    fn default() -> Self {
93        Self {
94            path: default_output_path(),
95        }
96    }
97}
98
99/// A kind's color: one value for every scheme, or a light and a dark
100/// one. Written as `"#3b82f6"` or `{ light = "#3b82f6", dark = "#1e40af" }`.
101/// Parsed by hand rather than as an untagged enum, whose only error is
102/// "did not match any variant": a mistake here names the key or value.
103#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
104#[serde(try_from = "toml::Value")]
105pub struct ThemeColor {
106    pub light: String,
107    pub dark: Option<String>,
108}
109
110impl TryFrom<toml::Value> for ThemeColor {
111    type Error = String;
112
113    fn try_from(value: toml::Value) -> std::result::Result<Self, String> {
114        let (light, dark) = match value {
115            toml::Value::String(light) => (light, None),
116            toml::Value::Table(table) => {
117                if let Some(key) = table
118                    .keys()
119                    .find(|k| !matches!(k.as_str(), "light" | "dark"))
120                {
121                    return Err(format!(
122                        "unknown key \"{key}\" in a theme color (expected light and optionally dark)"
123                    ));
124                }
125                let text = |key: &str| match table.get(key) {
126                    None => Ok(None),
127                    Some(toml::Value::String(s)) => Ok(Some(s.clone())),
128                    Some(other) => Err(format!(
129                        "theme color {key} is a {}, not a \"#rrggbb\" string",
130                        other.type_str()
131                    )),
132                };
133                let light = text("light")?.ok_or_else(|| {
134                    "a theme color table needs light (dark is optional)".to_string()
135                })?;
136                (light, text("dark")?)
137            }
138            other => {
139                return Err(format!(
140                    "a theme color is \"#rrggbb\" or {{ light = \"#rrggbb\", dark = \"#rrggbb\" }}, not a {}",
141                    other.type_str()
142                ));
143            }
144        };
145        for value in std::iter::once(&light).chain(dark.iter()) {
146            if !is_hex_color(value) {
147                return Err(format!(
148                    "theme color \"{value}\" is not #rrggbb (six hex digits after #)"
149                ));
150            }
151        }
152        Ok(ThemeColor { light, dark })
153    }
154}
155
156/// `#rrggbb`, six hex digits after the `#`.
157pub fn is_hex_color(value: &str) -> bool {
158    value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit())
159}
160
161impl Config {
162    /// The light color per themed kind, the emitter's input.
163    pub fn light_theme(&self) -> BTreeMap<String, String> {
164        self.theme
165            .iter()
166            .map(|(kind, color)| (kind.clone(), color.light.clone()))
167            .collect()
168    }
169}
170
171/// What `asbuilt docs` cannot read off the code.
172#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
173#[serde(deny_unknown_fields)]
174pub struct DocsConfig {
175    /// The site title; the root directory's name when absent.
176    #[serde(default)]
177    pub title: Option<String>,
178    /// A URL prefix that turns a repo-relative path into a link, e.g.
179    /// `https://github.com/owner/repo/blob/main/`. Paths stay plain text
180    /// when absent.
181    #[serde(default)]
182    pub source_url: Option<String>,
183    /// A link back to the site that hosts the tree, first in every
184    /// page's header trail; relative values (`../`) resolve
185    /// from each page's depth. No link when absent.
186    #[serde(default)]
187    pub home_url: Option<String>,
188    /// The text of that link; the URL itself when absent. Every page's
189    /// `<title>` starts with it.
190    #[serde(default)]
191    pub home_title: Option<String>,
192    /// A stylesheet linked last in every page, after the tree's own, so
193    /// the host can restate the page tokens in its palette. Resolved
194    /// like `home_url`.
195    #[serde(default)]
196    pub stylesheet: Option<String>,
197    /// The scheme the pages show before a visitor chooses one; the
198    /// system's setting when absent.
199    #[serde(default)]
200    pub color_scheme: Option<ColorScheme>,
201    /// Whether every page carries a System / Light / Dark control, and
202    /// with it `theme.js`. On when absent.
203    #[serde(default)]
204    pub scheme_toggle: Option<bool>,
205    /// Whether every inlined view gets the viewer (`viewer.js`): a frame
206    /// at a readable scale with Fit, 1:1, Wide, Legend and Fullscreen
207    /// controls.
208    /// On when absent.
209    #[serde(default)]
210    pub viewer: Option<bool>,
211}
212
213/// The color scheme the documentation pages show before a visitor
214/// chooses one: the system's setting, or light or dark regardless of it.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
216#[serde(rename_all = "lowercase")]
217pub enum ColorScheme {
218    #[default]
219    System,
220    Light,
221    Dark,
222}
223
224impl ColorScheme {
225    /// The name as `asbuilt.toml` and the `--color-scheme` flag spell it.
226    pub fn as_str(self) -> &'static str {
227        match self {
228            ColorScheme::System => "system",
229            ColorScheme::Light => "light",
230            ColorScheme::Dark => "dark",
231        }
232    }
233}
234
235impl FromStr for ColorScheme {
236    type Err = String;
237
238    fn from_str(text: &str) -> std::result::Result<Self, String> {
239        match text {
240            "system" => Ok(ColorScheme::System),
241            "light" => Ok(ColorScheme::Light),
242            "dark" => Ok(ColorScheme::Dark),
243            other => Err(format!(
244                "color scheme \"{other}\" is not system, light or dark"
245            )),
246        }
247    }
248}
249
250/// Something outside the code that a module talks to: a spawned
251/// process, a browser, a service. Nothing static says it exists, so the
252/// config does.
253#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct External {
256    /// A LikeC4 identifier; must not collide with a generated id.
257    pub id: String,
258    /// The LikeC4 element kind (`process`, `browser`, ...).
259    pub kind: String,
260    pub title: String,
261    #[serde(default)]
262    pub technology: Option<String>,
263    #[serde(default)]
264    pub description: Option<String>,
265    #[serde(default)]
266    pub relations: Vec<ExternalRelation>,
267}
268
269/// An edge from a generated element (or another external) to this one.
270#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub struct ExternalRelation {
273    /// A dotted id that must name a surveyed element or a sibling external.
274    pub from: String,
275    /// The label.
276    pub title: String,
277    #[serde(default)]
278    pub technology: Option<String>,
279}
280
281/// Parsing alone: the pin's place and shape are checked by the loaders,
282/// which know the file to name.
283impl FromStr for Config {
284    type Err = toml::de::Error;
285
286    fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
287        toml::from_str(text)
288    }
289}
290
291impl Config {
292    /// The config at `root/asbuilt.toml`, or the defaults when there is
293    /// no such file.
294    pub fn load(root: &Path) -> Result<Self> {
295        Self::locate_and_parse(root, None, None)
296    }
297
298    /// A config from an explicit path (`--config`); a missing file is an
299    /// error here, unlike [`Config::load`].
300    pub fn load_file(path: &Path) -> Result<Self> {
301        Self::parse_file(path, None)
302    }
303
304    /// The config a binary at release `running` reads: `config_path`
305    /// when given, else `root/asbuilt.toml`, else the defaults. The pin
306    /// is checked against `running` before the rest of the file is
307    /// deserialized, so a config written for another release fails on
308    /// the version, not on a table or key this release does not know.
309    pub fn load_for(root: &Path, config_path: Option<&Path>, running: &str) -> Result<Self> {
310        Self::locate_and_parse(root, config_path, Some(running))
311    }
312
313    fn locate_and_parse(
314        root: &Path,
315        config_path: Option<&Path>,
316        running: Option<&str>,
317    ) -> Result<Self> {
318        let path = match config_path {
319            Some(path) => path.to_path_buf(),
320            None => {
321                let path = root.join(FILE_NAME);
322                if !path.is_file() {
323                    return Ok(Self::default());
324                }
325                path
326            }
327        };
328        Self::parse_file(&path, running)
329    }
330
331    /// The file at `path`, its pin compared with `running` (when given)
332    /// before the rest is deserialized.
333    fn parse_file(path: &Path, running: Option<&str>) -> Result<Self> {
334        let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
335            path: path.to_path_buf(),
336            source,
337        })?;
338        let config_error = |source| Error::Config {
339            path: path.to_path_buf(),
340            source,
341        };
342        // Parsed twice: once for the pin alone, so a config written for
343        // another release fails on the version before any key this one
344        // does not know, then whole from the text, so a schema error
345        // keeps its line and column.
346        let table: toml::Table = toml::from_str(&text).map_err(config_error)?;
347        if let Some(running) = running {
348            check_pin(&table, path, running)?;
349        }
350        let mut config: Config = text.parse().map_err(config_error)?;
351        config.path = Some(path.to_path_buf());
352        Ok(config)
353    }
354
355    /// The file this config came from, for error messages: `asbuilt.toml`
356    /// when it was parsed from a string or defaulted.
357    pub fn file_path(&self) -> PathBuf {
358        self.path
359            .clone()
360            .unwrap_or_else(|| PathBuf::from(FILE_NAME))
361    }
362
363    /// Every top-level entry is asbuilt's own or the table of one of
364    /// `frontends` (by [`Frontend::name`](crate::Frontend::name)); the
365    /// first that is neither is an error naming it as written.
366    pub fn check_tables(&self, frontends: &[&str]) -> Result<()> {
367        let Some((name, value)) = self
368            .sections
369            .iter()
370            .find(|(name, _)| !frontends.contains(&name.as_str()))
371        else {
372            return Ok(());
373        };
374        let entry = match value {
375            toml::Value::Table(_) => format!("[{name}]"),
376            toml::Value::Array(items)
377                if !items.is_empty() && items.iter().all(toml::Value::is_table) =>
378            {
379                format!("[[{name}]]")
380            }
381            _ => format!("{name} = ..."),
382        };
383        let known = std::iter::once(format!("{PIN_KEY} = \"X.Y.Z\""))
384            .chain(OWN_TABLES.iter().map(|t| t.to_string()))
385            .chain(frontends.iter().map(|name| format!("[{name}]")))
386            .collect::<Vec<_>>()
387            .join(", ");
388        Err(Error::UnknownTopLevel {
389            path: self.file_path(),
390            entry,
391            known,
392        })
393    }
394
395    /// A front-end's own table, deserialized with its schema. `Ok(None)`
396    /// when the table is absent; a `Config` error naming the file when
397    /// it is present and does not fit.
398    pub fn section<T: DeserializeOwned>(&self, name: &str) -> Result<Option<T>> {
399        let Some(value) = self.sections.get(name) else {
400            return Ok(None);
401        };
402        value
403            .clone()
404            .try_into()
405            .map(Some)
406            .map_err(|source| Error::Config {
407                path: self.file_path(),
408                source,
409            })
410    }
411}
412
413/// The pin against the `running` release. A pin that is not a release
414/// version passes here and fails deserializing, which says where.
415fn check_pin(table: &toml::Table, path: &Path, running: &str) -> Result<()> {
416    match table.get(PIN_KEY).and_then(toml::Value::as_str) {
417        Some(pinned) if is_release_version(pinned) && pinned != running => {
418            Err(Error::PinMismatch {
419                path: path.to_path_buf(),
420                pinned: pinned.to_string(),
421                running: running.to_string(),
422            })
423        }
424        _ => Ok(()),
425    }
426}
427
428/// `asbuilt = "X.Y.Z"`, refused unless it is a release version.
429fn release_pin<'de, D: Deserializer<'de>>(
430    deserializer: D,
431) -> std::result::Result<Option<String>, D::Error> {
432    let pin = String::deserialize(deserializer)?;
433    if !is_release_version(&pin) {
434        return Err(D::Error::custom(format!(
435            "asbuilt = \"{pin}\" is not a release version (\"X.Y.Z\", as `cargo install --version` takes it)"
436        )));
437    }
438    Ok(Some(pin))
439}
440
441/// A release as `cargo install --version` takes it and
442/// `CARGO_PKG_VERSION` reports it: `MAJOR.MINOR.PATCH` with an optional
443/// `-pre` release, as semver writes them. No `+build`: the pin is
444/// compared as written, and build metadata never tells releases apart.
445fn is_release_version(text: &str) -> bool {
446    let (core, pre) = match text.split_once('-') {
447        Some((core, pre)) => (core, Some(pre)),
448        None => (text, None),
449    };
450    let numbers: Vec<&str> = core.split('.').collect();
451    numbers.len() == 3
452        && numbers.iter().all(|n| is_number(n))
453        && pre.is_none_or(|pre| {
454            pre.split('.').all(|id| {
455                !id.is_empty()
456                    && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
457                    && (!id.bytes().all(|b| b.is_ascii_digit()) || is_number(id))
458            })
459        })
460}
461
462/// Digits that fit a u64, with no leading zero unless it is `0`.
463fn is_number(text: &str) -> bool {
464    text.bytes().all(|b| b.is_ascii_digit())
465        && text.parse::<u64>().is_ok()
466        && (text == "0" || !text.starts_with('0'))
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    const BRIEF_EXAMPLE: &str = r#"
474[output]
475path = "docs/architecture/model.c4"
476
477[rust]
478extra_manifests = ["crates/site/Cargo.toml", "crates/site-e2e/Cargo.toml"]
479include_tests = true
480
481[[externals]]
482id = "node_driver"
483kind = "process"
484title = "Playwright driver"
485technology = "Node.js process"
486description = "The official Playwright server, assembled by build.rs."
487
488[[externals.relations]]
489from = "playwright_rs.server.playwright_server"
490title = "spawns"
491technology = "stdio"
492"#;
493
494    #[derive(Debug, PartialEq, Deserialize)]
495    #[serde(deny_unknown_fields)]
496    struct RustLike {
497        extra_manifests: Vec<String>,
498        include_tests: bool,
499    }
500
501    fn unknown(text: &str) -> (PathBuf, String, String) {
502        let config: Config = text.parse().unwrap();
503        match config.check_tables(&["rust"]) {
504            Err(Error::UnknownTopLevel { path, entry, known }) => (path, entry, known),
505            other => panic!("expected UnknownTopLevel, got {other:?}"),
506        }
507    }
508
509    #[test]
510    fn a_misspelled_table_is_named_as_written() {
511        assert_eq!(unknown("[rsut]\ninclude_tests = false\n").1, "[rsut]");
512    }
513
514    #[test]
515    fn a_singular_externals_table_is_named_as_an_array_of_tables() {
516        let (_, entry, _) =
517            unknown("[[external]]\nid = \"d\"\nkind = \"process\"\ntitle = \"D\"\n");
518        assert_eq!(entry, "[[external]]");
519    }
520
521    #[test]
522    fn a_stray_top_level_key_is_named_as_a_key() {
523        assert_eq!(unknown("title = \"My docs\"\n").1, "title = ...");
524    }
525
526    #[test]
527    fn a_stray_top_level_list_is_named_as_a_key() {
528        assert_eq!(unknown("tags = [\"a\"]\n").1, "tags = ...");
529    }
530
531    #[test]
532    fn a_stray_empty_list_is_named_as_a_key() {
533        assert_eq!(unknown("tags = []\n").1, "tags = ...");
534    }
535
536    #[test]
537    fn an_unknown_entry_lists_asbuilts_tables_then_the_front_ends() {
538        assert_eq!(
539            unknown("[rsut]\n").2,
540            "asbuilt = \"X.Y.Z\", [output], [docs], [theme], [[externals]], [rust]"
541        );
542    }
543
544    #[test]
545    fn an_unknown_entry_in_a_defaulted_config_names_the_default_file() {
546        assert_eq!(unknown("[rsut]\n").0, PathBuf::from("asbuilt.toml"));
547    }
548
549    #[test]
550    fn an_unknown_entry_in_a_loaded_config_names_that_file() {
551        let dir = tempfile::tempdir().unwrap();
552        let path = dir.path().join("elsewhere.toml");
553        std::fs::write(&path, "[rsut]\n").unwrap();
554        let config = Config::load_file(&path).unwrap();
555        assert!(matches!(
556            config.check_tables(&["rust"]),
557            Err(Error::UnknownTopLevel { path: named, .. }) if named == path
558        ));
559    }
560
561    #[test]
562    fn a_front_ends_table_and_asbuilts_own_pass_the_check() {
563        let config: Config = BRIEF_EXAMPLE.parse().unwrap();
564        assert!(config.check_tables(&["rust"]).is_ok());
565    }
566
567    #[test]
568    fn the_brief_example_parses_to_its_values() {
569        let config: Config = BRIEF_EXAMPLE.parse().unwrap();
570        assert_eq!(config.output.path, "docs/architecture/model.c4");
571        assert_eq!(config.externals.len(), 1);
572        let ext = &config.externals[0];
573        assert_eq!(ext.id, "node_driver");
574        assert_eq!(ext.kind, "process");
575        assert_eq!(ext.technology.as_deref(), Some("Node.js process"));
576        assert_eq!(
577            ext.relations,
578            vec![ExternalRelation {
579                from: "playwright_rs.server.playwright_server".into(),
580                title: "spawns".into(),
581                technology: Some("stdio".into()),
582            }]
583        );
584    }
585
586    #[test]
587    fn a_front_end_table_round_trips_through_its_own_schema() {
588        let config: Config = BRIEF_EXAMPLE.parse().unwrap();
589        let rust: Option<RustLike> = config.section("rust").unwrap();
590        assert_eq!(
591            rust,
592            Some(RustLike {
593                extra_manifests: vec![
594                    "crates/site/Cargo.toml".into(),
595                    "crates/site-e2e/Cargo.toml".into()
596                ],
597                include_tests: true,
598            })
599        );
600    }
601
602    #[test]
603    fn an_absent_section_is_none() {
604        let config: Config = "".parse().unwrap();
605        let rust: Option<RustLike> = config.section("rust").unwrap();
606        assert_eq!(rust, None);
607    }
608
609    #[test]
610    fn a_section_that_does_not_fit_its_schema_is_a_config_error_naming_the_file() {
611        let mut config: Config = "[rust]\ninclude_test = true\n".parse().unwrap();
612        config.path = Some(PathBuf::from("here/asbuilt.toml"));
613        match config.section::<RustLike>("rust") {
614            Err(Error::Config { path, .. }) => assert_eq!(path, PathBuf::from("here/asbuilt.toml")),
615            other => panic!("expected Config error, got {other:?}"),
616        }
617    }
618
619    #[test]
620    fn an_empty_config_has_the_default_output_path() {
621        let config: Config = "".parse().unwrap();
622        assert_eq!(config.output.path, DEFAULT_OUTPUT_PATH);
623    }
624
625    #[test]
626    fn an_unknown_key_under_output_is_rejected() {
627        let result: std::result::Result<Config, _> = "[output]\npth = \"x\"\n".parse();
628        assert!(result.is_err(), "got {result:?}");
629    }
630
631    #[test]
632    fn an_unknown_key_in_an_external_is_rejected() {
633        let result: std::result::Result<Config, _> =
634            "[[externals]]\nid = \"x\"\nkind = \"k\"\ntitle = \"t\"\nlabel = \"no\"\n".parse();
635        assert!(result.is_err(), "got {result:?}");
636    }
637
638    #[test]
639    fn the_docs_table_parses_and_defaults_to_nothing() {
640        let config: Config = "[docs]\ntitle = \"asbuilt\"\nsource_url = \"https://x/blob/main/\"\nhome_url = \"../\"\nhome_title = \"Home\"\nstylesheet = \"../site.css\"\n"
641            .parse()
642            .unwrap();
643        assert_eq!(config.docs.title.as_deref(), Some("asbuilt"));
644        assert_eq!(
645            config.docs.source_url.as_deref(),
646            Some("https://x/blob/main/")
647        );
648        assert_eq!(config.docs.home_url.as_deref(), Some("../"));
649        assert_eq!(config.docs.home_title.as_deref(), Some("Home"));
650        assert_eq!(config.docs.stylesheet.as_deref(), Some("../site.css"));
651        let empty: Config = "".parse().unwrap();
652        assert_eq!(empty.docs, DocsConfig::default());
653    }
654
655    #[test]
656    fn a_theme_color_is_a_string_or_a_light_dark_pair() {
657        let config: Config =
658            "[theme]\ncontainer = \"#3b82f6\"\nprocess = { light = \"#c9c9c9\", dark = \"#444444\" }\n"
659                .parse()
660                .unwrap();
661        assert_eq!(
662            config.theme["container"],
663            ThemeColor {
664                light: "#3b82f6".into(),
665                dark: None
666            }
667        );
668        assert_eq!(
669            config.theme["process"],
670            ThemeColor {
671                light: "#c9c9c9".into(),
672                dark: Some("#444444".into())
673            }
674        );
675        assert_eq!(
676            config.light_theme(),
677            BTreeMap::from([
678                ("container".to_string(), "#3b82f6".to_string()),
679                ("process".to_string(), "#c9c9c9".to_string()),
680            ])
681        );
682    }
683
684    #[test]
685    fn a_theme_color_that_is_not_six_hex_digits_is_rejected_naming_the_value() {
686        for bad in ["blue", "#fff", "#12345g", "3b82f6"] {
687            let result: std::result::Result<Config, _> =
688                format!("[theme]\ncontainer = \"{bad}\"\n").parse();
689            let err = result.err().map(|e| e.to_string()).unwrap_or_default();
690            assert!(err.contains(bad), "{bad}: {err}");
691        }
692        let result: std::result::Result<Config, _> =
693            "[theme]\ncontainer = { light = \"#3b82f6\", dark = \"nope\" }\n".parse();
694        assert!(result.is_err(), "{result:?}");
695    }
696
697    fn theme_error(color: &str) -> String {
698        format!("[theme]\ncontainer = {color}\n")
699            .parse::<Config>()
700            .err()
701            .map(|e| e.to_string())
702            .unwrap_or_default()
703    }
704
705    #[test]
706    fn an_unknown_key_in_a_theme_color_is_rejected_naming_it() {
707        let err = theme_error("{ light = \"#f0a884\", drak = \"#b5673f\" }");
708        assert!(err.contains("unknown key \"drak\""), "{err}");
709    }
710
711    #[test]
712    fn a_theme_color_table_without_light_is_rejected_saying_so() {
713        let err = theme_error("{ dark = \"#b5673f\" }");
714        assert!(err.contains("needs light"), "{err}");
715    }
716
717    #[test]
718    fn a_theme_color_of_another_type_is_rejected_naming_the_type() {
719        assert!(
720            theme_error("3").contains("not a integer"),
721            "{}",
722            theme_error("3")
723        );
724        let err = theme_error("{ light = 3 }");
725        assert!(err.contains("light is a integer"), "{err}");
726    }
727
728    #[test]
729    fn the_docs_viewer_key_parses_and_defaults_to_nothing() {
730        let config: Config = "[docs]\nviewer = false\n".parse().unwrap();
731        assert_eq!(config.docs.viewer, Some(false));
732        let empty: Config = "[docs]\n".parse().unwrap();
733        assert_eq!(empty.docs.viewer, None);
734    }
735
736    #[test]
737    fn the_docs_color_scheme_and_toggle_parse_and_default_to_nothing() {
738        let config: Config = "[docs]\ncolor_scheme = \"dark\"\nscheme_toggle = false\n"
739            .parse()
740            .unwrap();
741        assert_eq!(config.docs.color_scheme, Some(ColorScheme::Dark));
742        assert_eq!(config.docs.scheme_toggle, Some(false));
743        let empty: Config = "[docs]\n".parse().unwrap();
744        assert_eq!(empty.docs.color_scheme, None);
745        assert_eq!(empty.docs.scheme_toggle, None);
746    }
747
748    #[test]
749    fn a_color_scheme_that_is_not_one_of_the_three_is_rejected_naming_them() {
750        let err = "[docs]\ncolor_scheme = \"sepia\"\n"
751            .parse::<Config>()
752            .unwrap_err()
753            .to_string();
754        assert!(err.contains("sepia") && err.contains("dark"), "{err}");
755    }
756
757    #[test]
758    fn a_color_scheme_reads_from_its_name_and_prints_back_the_same() {
759        for scheme in [ColorScheme::System, ColorScheme::Light, ColorScheme::Dark] {
760            assert_eq!(scheme.as_str().parse::<ColorScheme>(), Ok(scheme));
761        }
762        assert_eq!(
763            "sepia".parse::<ColorScheme>(),
764            Err("color scheme \"sepia\" is not system, light or dark".to_string())
765        );
766    }
767
768    #[test]
769    fn an_unknown_key_under_docs_is_rejected() {
770        let result: std::result::Result<Config, _> = "[docs]\ntitel = \"x\"\n".parse();
771        assert!(result.is_err(), "got {result:?}");
772    }
773
774    #[test]
775    fn an_external_without_relations_has_none() {
776        let config: Config = "[[externals]]\nid = \"x\"\nkind = \"k\"\ntitle = \"t\"\n"
777            .parse()
778            .unwrap();
779        assert!(config.externals[0].relations.is_empty());
780    }
781
782    #[test]
783    fn a_missing_file_at_the_root_loads_the_defaults() {
784        let dir = tempfile::tempdir().unwrap();
785        let config = Config::load(dir.path()).unwrap();
786        assert_eq!(config, Config::default());
787    }
788
789    #[test]
790    fn a_file_at_the_root_is_loaded_and_remembers_its_path() {
791        let dir = tempfile::tempdir().unwrap();
792        let path = dir.path().join(FILE_NAME);
793        std::fs::write(&path, "[output]\npath = \"m.c4\"\n").unwrap();
794        let config = Config::load(dir.path()).unwrap();
795        assert_eq!(config.output.path, "m.c4");
796        assert_eq!(config.path, Some(path));
797    }
798
799    #[test]
800    fn a_malformed_file_is_a_config_error_naming_the_file() {
801        let dir = tempfile::tempdir().unwrap();
802        let path = dir.path().join(FILE_NAME);
803        std::fs::write(&path, "[output\n").unwrap();
804        match Config::load(dir.path()) {
805            Err(Error::Config { path: p, .. }) => assert_eq!(p, path),
806            other => panic!("expected Config error, got {other:?}"),
807        }
808    }
809
810    /// `text` as the root's `asbuilt.toml`, loaded by a binary at
811    /// `running`, with the file's path.
812    fn load_pinned(text: &str, running: &str) -> (Result<Config>, PathBuf) {
813        let dir = tempfile::tempdir().unwrap();
814        let path = dir.path().join(FILE_NAME);
815        std::fs::write(&path, text).unwrap();
816        (Config::load_for(dir.path(), None, running), path)
817    }
818
819    #[test]
820    fn a_pin_naming_the_running_release_loads_and_keeps_the_pin() {
821        let (config, _) = load_pinned("asbuilt = \"0.3.0\"\n", "0.3.0");
822        assert_eq!(config.unwrap().asbuilt.as_deref(), Some("0.3.0"));
823    }
824
825    #[test]
826    fn a_pin_naming_another_release_is_a_mismatch_naming_the_file_and_both_releases() {
827        let (config, path) = load_pinned("asbuilt = \"0.3.0\"\n", "0.2.0");
828        assert!(matches!(
829            config,
830            Err(Error::PinMismatch { path: p, pinned, running })
831                if p == path && pinned == "0.3.0" && running == "0.2.0"
832        ));
833    }
834
835    #[test]
836    fn a_pre_release_is_another_release() {
837        let (config, _) = load_pinned("asbuilt = \"0.3.0-rc.1\"\n", "0.3.0");
838        assert!(
839            matches!(config, Err(Error::PinMismatch { .. })),
840            "{config:?}"
841        );
842    }
843
844    #[test]
845    fn load_without_a_running_release_accepts_any_pin() {
846        let dir = tempfile::tempdir().unwrap();
847        std::fs::write(dir.path().join(FILE_NAME), "asbuilt = \"9.9.9\"\n").unwrap();
848        let config = Config::load(dir.path()).unwrap();
849        assert_eq!(config.asbuilt.as_deref(), Some("9.9.9"));
850    }
851
852    #[test]
853    fn a_schema_error_from_a_file_keeps_its_place_in_the_text() {
854        let (config, _) = load_pinned("[output]\npth = \"x\"\n", "0.3.0");
855        match config {
856            Err(Error::Config { source, .. }) => assert!(source.span().is_some(), "{source}"),
857            other => panic!("expected Config error, got {other:?}"),
858        }
859    }
860
861    #[test]
862    fn the_pin_is_checked_before_a_key_this_release_does_not_know() {
863        let (config, _) = load_pinned("asbuilt = \"0.9.0\"\n\n[docs]\nnewer = true\n", "0.3.0");
864        assert!(
865            matches!(config, Err(Error::PinMismatch { .. })),
866            "{config:?}"
867        );
868    }
869
870    #[test]
871    fn a_config_without_a_pin_loads_under_any_release() {
872        let (config, _) = load_pinned("[output]\npath = \"m.c4\"\n", "0.3.0");
873        assert_eq!(config.unwrap().asbuilt, None);
874    }
875
876    #[test]
877    fn no_file_at_the_root_loads_the_defaults_for_any_release() {
878        let dir = tempfile::tempdir().unwrap();
879        let config = Config::load_for(dir.path(), None, "0.3.0").unwrap();
880        assert_eq!(config, Config::default());
881    }
882
883    #[test]
884    fn an_explicit_path_is_read_in_place_of_the_roots_file() {
885        let dir = tempfile::tempdir().unwrap();
886        std::fs::write(dir.path().join(FILE_NAME), "asbuilt = \"0.1.0\"\n").unwrap();
887        let other = dir.path().join("other.toml");
888        std::fs::write(&other, "asbuilt = \"0.3.0\"\n").unwrap();
889        let config = Config::load_for(dir.path(), Some(&other), "0.3.0").unwrap();
890        assert_eq!(config.path, Some(other));
891    }
892
893    #[test]
894    fn a_malformed_file_is_still_a_config_error_when_loaded_for_a_release() {
895        let (config, path) = load_pinned("asbuilt = \"0.3.0\"\n[output\n", "0.3.0");
896        assert!(matches!(config, Err(Error::Config { path: p, .. }) if p == path));
897    }
898
899    /// The error from parsing `text`, with where it points.
900    fn pin_error(text: &str) -> Option<std::ops::Range<usize>> {
901        text.parse::<Config>().err().and_then(|e| e.span())
902    }
903
904    #[test]
905    fn a_pin_that_is_not_a_release_version_is_refused_where_it_is_written() {
906        let text = "asbuilt = \"v0.3.0\"\n";
907        assert_eq!(pin_error(text), Some(10..18));
908    }
909
910    #[test]
911    fn a_pin_that_is_not_a_string_is_refused_where_it_is_written() {
912        assert_eq!(pin_error("asbuilt = 3\n"), Some(10..11));
913    }
914
915    #[test]
916    fn a_pin_written_as_a_table_is_refused() {
917        assert!(
918            "[asbuilt]\nversion = \"0.3.0\"\n"
919                .parse::<Config>()
920                .is_err()
921        );
922    }
923
924    #[test]
925    fn an_invalid_pin_from_a_file_is_a_config_error_naming_it() {
926        let (config, path) = load_pinned("asbuilt = \"v0.3.0\"\n", "0.3.0");
927        assert!(matches!(config, Err(Error::Config { path: p, .. }) if p == path));
928    }
929
930    #[test]
931    fn a_release_version_is_semver_as_cargo_install_takes_it() {
932        for good in [
933            "0.3.0",
934            "10.20.30",
935            "1.0.0-rc.1",
936            "1.0.0-alpha-2",
937            "1.0.0-0",
938            "1.0.0-0a.01x",
939        ] {
940            assert!(is_release_version(good), "{good}");
941        }
942        for bad in [
943            "",
944            "v0.3.0",
945            "0.3",
946            "0.3.0.1",
947            "0.3.x",
948            "0..3",
949            "0.3.0-",
950            "0.3.0-rc!",
951            "0.03.0",
952            "01.2.3",
953            "1.0.0-01",
954            "1.0.0-rc..1",
955            "1.0.0+build",
956            "1.0.0-rc.1+b",
957            "99999999999999999999.0.0",
958        ] {
959            assert!(!is_release_version(bad), "{bad}");
960        }
961    }
962
963    #[test]
964    fn an_explicit_path_that_does_not_exist_is_an_io_error_naming_it() {
965        let missing = PathBuf::from("/no/such/asbuilt.toml");
966        match Config::load_file(&missing) {
967            Err(Error::Io { path, .. }) => assert_eq!(path, missing),
968            other => panic!("expected Io error, got {other:?}"),
969        }
970    }
971}