1use 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
14pub const DEFAULT_OUTPUT_PATH: &str = "docs/architecture/model.c4";
16
17pub const FILE_NAME: &str = "asbuilt.toml";
19
20pub const PIN_KEY: &str = "asbuilt";
23
24const OWN_TABLES: [&str; 4] = ["[output]", "[docs]", "[theme]", "[[externals]]"];
26
27#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
52pub struct Config {
53 #[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 #[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 #[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 #[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#[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
156pub 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 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
173#[serde(deny_unknown_fields)]
174pub struct DocsConfig {
175 #[serde(default)]
177 pub title: Option<String>,
178 #[serde(default)]
182 pub source_url: Option<String>,
183 #[serde(default)]
187 pub home_url: Option<String>,
188 #[serde(default)]
191 pub home_title: Option<String>,
192 #[serde(default)]
196 pub stylesheet: Option<String>,
197 #[serde(default)]
200 pub color_scheme: Option<ColorScheme>,
201 #[serde(default)]
204 pub scheme_toggle: Option<bool>,
205 #[serde(default)]
210 pub viewer: Option<bool>,
211}
212
213#[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 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
254#[serde(deny_unknown_fields)]
255pub struct External {
256 pub id: String,
258 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub struct ExternalRelation {
273 pub from: String,
275 pub title: String,
277 #[serde(default)]
278 pub technology: Option<String>,
279}
280
281impl 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 pub fn load(root: &Path) -> Result<Self> {
295 Self::locate_and_parse(root, None, None)
296 }
297
298 pub fn load_file(path: &Path) -> Result<Self> {
301 Self::parse_file(path, None)
302 }
303
304 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 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 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 pub fn file_path(&self) -> PathBuf {
358 self.path
359 .clone()
360 .unwrap_or_else(|| PathBuf::from(FILE_NAME))
361 }
362
363 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 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
413fn 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
428fn 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
441fn 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
462fn 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 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 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}