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/// asbuilt's own top-level tables, as the file writes them.
21const OWN_TABLES: [&str; 4] = ["[output]", "[docs]", "[theme]", "[[externals]]"];
22
23/// The parsed `asbuilt.toml`.
24///
25/// Other top-level entries stay in `sections`, where a front-end's table
26/// (`[rust]`, say) waits to be deserialized with that front-end's schema;
27/// [`Config::check_tables`] rejects any that names no front-end. Unknown
28/// keys *inside* `[output]` or an `[[externals]]` entry are rejected
29/// when parsing.
30///
31/// ```
32/// use asbuilt_core::Config;
33///
34/// let config: Config = r#"
35/// [output]
36/// path = "arch/model.c4"
37///
38/// [rust]
39/// include_tests = false
40/// "#
41/// .parse()
42/// .unwrap();
43///
44/// assert_eq!(config.output.path, "arch/model.c4");
45/// assert!(config.sections.contains_key("rust"));
46/// ```
47#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
48pub struct Config {
49    #[serde(default)]
50    pub output: OutputConfig,
51    #[serde(default)]
52    pub docs: DocsConfig,
53    /// `[theme]`: a color per element kind (`container`, `component`,
54    /// `bin`, `tests`, `examples`, or an external kind), emitted as
55    /// LikeC4 element styles so the
56    /// diagrams come out in the consumer's palette.
57    #[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    /// Where this config was read from, for error messages. `None` for a
64    /// config parsed from a string or defaulted.
65    #[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    /// Relative to the surveyed root.
73    #[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/// A kind's color: one value for every scheme, or a light and a dark
90/// one. Written as `"#3b82f6"` or `{ light = "#3b82f6", dark = "#1e40af" }`.
91/// Parsed by hand rather than as an untagged enum, whose only error is
92/// "did not match any variant": a mistake here names the key or value.
93#[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
146/// `#rrggbb`, six hex digits after the `#`.
147pub 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    /// The light color per themed kind, the emitter's input.
153    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/// What `asbuilt docs` cannot read off the code.
162#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
163#[serde(deny_unknown_fields)]
164pub struct DocsConfig {
165    /// The site title; the root directory's name when absent.
166    #[serde(default)]
167    pub title: Option<String>,
168    /// A URL prefix that turns a repo-relative path into a link, e.g.
169    /// `https://github.com/owner/repo/blob/main/`. Paths stay plain text
170    /// when absent.
171    #[serde(default)]
172    pub source_url: Option<String>,
173    /// A link back to the site that hosts the tree, first in every
174    /// page's header trail; relative values (`../`) resolve
175    /// from each page's depth. No link when absent.
176    #[serde(default)]
177    pub home_url: Option<String>,
178    /// The text of that link; the URL itself when absent. Every page's
179    /// `<title>` starts with it.
180    #[serde(default)]
181    pub home_title: Option<String>,
182    /// A stylesheet linked last in every page, after the tree's own, so
183    /// the host can restate the page tokens in its palette. Resolved
184    /// like `home_url`.
185    #[serde(default)]
186    pub stylesheet: Option<String>,
187    /// The scheme the pages show before a visitor chooses one; the
188    /// system's setting when absent.
189    #[serde(default)]
190    pub color_scheme: Option<ColorScheme>,
191    /// Whether every page carries a System / Light / Dark control, and
192    /// with it `theme.js`. On when absent.
193    #[serde(default)]
194    pub scheme_toggle: Option<bool>,
195    /// Whether every inlined view gets the viewer (`viewer.js`): a frame
196    /// at a readable scale with Fit, 1:1, Wide and Fullscreen controls.
197    /// On when absent.
198    #[serde(default)]
199    pub viewer: Option<bool>,
200}
201
202/// The color scheme the documentation pages show before a visitor
203/// chooses one: the system's setting, or light or dark regardless of it.
204#[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    /// The name as `asbuilt.toml` and the `--color-scheme` flag spell it.
215    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/// Something outside the code that a module talks to: a spawned
240/// process, a browser, a service. Nothing static says it exists, so the
241/// config does.
242#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
243#[serde(deny_unknown_fields)]
244pub struct External {
245    /// A LikeC4 identifier; must not collide with a generated id.
246    pub id: String,
247    /// The LikeC4 element kind (`process`, `browser`, ...).
248    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/// An edge from a generated element (or another external) to this one.
259#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
260#[serde(deny_unknown_fields)]
261pub struct ExternalRelation {
262    /// A dotted id that must name a surveyed element or a sibling external.
263    pub from: String,
264    /// The label.
265    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    /// The config at `root/asbuilt.toml`, or the defaults when there is
280    /// no such file.
281    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    /// A config from an explicit path (`--config`); a missing file is an
290    /// error here, unlike [`Config::load`].
291    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    /// The file this config came from, for error messages: `asbuilt.toml`
305    /// when it was parsed from a string or defaulted.
306    pub fn file_path(&self) -> PathBuf {
307        self.path
308            .clone()
309            .unwrap_or_else(|| PathBuf::from(FILE_NAME))
310    }
311
312    /// Every top-level entry is asbuilt's own or the table of one of
313    /// `frontends` (by [`Frontend::name`](crate::Frontend::name)); the
314    /// first that is neither is an error naming it as written.
315    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    /// A front-end's own table, deserialized with its schema. `Ok(None)`
346    /// when the table is absent; a `Config` error naming the file when
347    /// it is present and does not fit.
348    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}