1use std::{collections::HashMap, fmt::Display, path::PathBuf};
2
3use indexmap::IndexMap;
4use rand::Rng;
5use semver::{Version, VersionReq};
6use serde::{Deserialize, Serialize};
7use sha2::Digest;
8use thiserror::Error;
9
10use crate::{
11 Component, Concept, Enum, ItemPathBuf, Message, PascalCaseIdentifier, SnakeCaseIdentifier,
12};
13
14#[derive(Error, Debug, PartialEq)]
15pub enum ManifestParseError {
16 #[error("manifest was not valid TOML: {0}")]
17 TomlError(#[from] toml::de::Error),
18 #[error("manifest contains a project and/or an ember section; projects/embers have been renamed to packages")]
19 ProjectEmberRenamedToPackageError,
20}
21
22#[derive(Deserialize, Clone, Debug, Default, PartialEq, Serialize)]
23pub struct Manifest {
24 pub package: Package,
25 #[serde(default)]
26 pub build: Build,
27 #[serde(default)]
28 #[serde(alias = "component")]
29 pub components: IndexMap<ItemPathBuf, Component>,
30 #[serde(default)]
31 #[serde(alias = "concept")]
32 pub concepts: IndexMap<ItemPathBuf, Concept>,
33 #[serde(default)]
34 #[serde(alias = "message")]
35 pub messages: IndexMap<ItemPathBuf, Message>,
36 #[serde(default)]
37 #[serde(alias = "enum")]
38 pub enums: IndexMap<PascalCaseIdentifier, Enum>,
39 #[serde(default)]
40 pub includes: HashMap<SnakeCaseIdentifier, PathBuf>,
41 #[serde(default)]
42 pub dependencies: IndexMap<SnakeCaseIdentifier, Dependency>,
43}
44impl Manifest {
45 pub fn parse(manifest: &str) -> Result<Self, ManifestParseError> {
46 let raw = toml::from_str::<toml::Table>(manifest)?;
47 if raw.contains_key("project") || raw.contains_key("ember") {
48 return Err(ManifestParseError::ProjectEmberRenamedToPackageError);
49 }
50
51 Ok(toml::from_str(manifest)?)
52 }
53
54 pub fn to_toml_string(&self) -> String {
55 toml::to_string_pretty(self).unwrap()
56 }
57}
58
59#[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq, Hash, Default, Serialize)]
60#[serde(transparent)]
61pub struct PackageId(pub(crate) String);
63impl<'de> Deserialize<'de> for PackageId {
64 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
65 where
66 D: serde::Deserializer<'de>,
67 {
68 PackageId::new(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
69 }
70}
71impl PackageId {
72 const DATA_LENGTH: usize = 12;
73 const CHECKSUM_LENGTH: usize = 8;
74 const TOTAL_LENGTH: usize = Self::DATA_LENGTH + Self::CHECKSUM_LENGTH;
75 #[allow(clippy::unusual_byte_groupings)]
81 const MAX_VALUE_FOR_FIRST_BYTE: u8 = 0b11001_111;
82
83 pub fn as_str(&self) -> &str {
84 &self.0
85 }
86
87 pub fn new(id: &str) -> Result<Self, String> {
89 Self::validate(id)?;
90 Ok(Self(id.to_string()))
91 }
92
93 pub fn generate() -> Self {
95 let mut data: [u8; Self::DATA_LENGTH] = rand::random();
96 data[0] = rand::thread_rng().gen_range(0..=Self::MAX_VALUE_FOR_FIRST_BYTE);
97 let checksum: [u8; Self::CHECKSUM_LENGTH] = sha2::Sha256::digest(data)
98 [0..Self::CHECKSUM_LENGTH]
99 .try_into()
100 .unwrap();
101
102 let mut bytes = [0u8; Self::TOTAL_LENGTH];
103 bytes[0..Self::DATA_LENGTH].copy_from_slice(&data);
104 bytes[Self::DATA_LENGTH..].copy_from_slice(&checksum);
105
106 let output = data_encoding::BASE32_NOPAD
107 .encode(&bytes)
108 .to_ascii_lowercase();
109
110 assert!(output.chars().next().unwrap().is_ascii_alphabetic());
111 Self(output)
112 }
113
114 pub fn validate(id: &str) -> Result<(), String> {
116 let cmd =
117 "Use `ambient package regenerate-id` to regenerate the package ID with the new format.";
118
119 let bytes = data_encoding::BASE32_NOPAD
120 .decode(id.to_ascii_uppercase().as_bytes())
121 .map_err(|e| format!("Package ID contained invalid characters: {e}. {cmd}"))?;
122
123 let data = &bytes[0..Self::DATA_LENGTH];
124 let checksum = &bytes[Self::DATA_LENGTH..];
125
126 let expected_checksum = &sha2::Sha256::digest(data)[0..Self::CHECKSUM_LENGTH];
127 if checksum != expected_checksum {
128 return Err(format!(
129 "Package ID contained invalid checksum: expected {:?}, got {:?}. {cmd}",
130 expected_checksum, checksum
131 ));
132 }
133
134 Ok(())
135 }
136}
137impl Display for PackageId {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 self.0.fmt(f)
140 }
141}
142impl From<PackageId> for SnakeCaseIdentifier {
143 fn from(id: PackageId) -> Self {
144 SnakeCaseIdentifier(id.0)
145 }
146}
147
148#[derive(Deserialize, Clone, Debug, PartialEq, Serialize)]
149pub struct Package {
150 #[serde(default)]
152 pub id: Option<PackageId>,
153 pub name: String,
154 pub version: Version,
155 pub description: Option<String>,
156 pub repository: Option<String>,
157 pub ambient_version: Option<VersionReq>,
158 #[serde(default)]
159 pub authors: Vec<String>,
160 pub content: PackageContent,
161 #[serde(default = "return_true")]
162 pub public: bool,
163}
164impl Default for Package {
165 fn default() -> Self {
166 Self {
167 id: Default::default(),
168 name: Default::default(),
169 version: Version::parse("0.0.0").unwrap(),
170 description: Default::default(),
171 repository: Default::default(),
172 ambient_version: Default::default(),
173 authors: Default::default(),
174 content: Default::default(),
175 public: true,
176 }
177 }
178}
179
180fn return_true() -> bool {
181 true
182}
183
184#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
187#[serde(tag = "type")]
188pub enum PackageContent {
189 Playable {
190 #[serde(default)]
191 example: bool,
192 },
193 Asset {
195 #[serde(default)]
196 models: bool,
197 #[serde(default)]
198 animations: bool,
199 #[serde(default)]
200 textures: bool,
201 #[serde(default)]
202 materials: bool,
203 #[serde(default)]
204 audio: bool,
205 #[serde(default)]
206 fonts: bool,
207 #[serde(default)]
208 code: bool,
209 #[serde(default)]
210 schema: bool,
211 },
212 Tool,
213 Mod {
214 #[serde(default)]
216 for_playables: Vec<String>,
217 },
218}
219impl Default for PackageContent {
220 fn default() -> Self {
221 Self::Playable { example: false }
222 }
223}
224
225#[derive(Deserialize, Clone, Debug, PartialEq, Default, Serialize)]
228pub struct Build {
229 #[serde(default)]
230 pub rust: BuildRust,
231}
232
233#[derive(Deserialize, Clone, Debug, PartialEq, Serialize)]
234pub struct BuildRust {
235 #[serde(rename = "feature-multibuild")]
236 pub feature_multibuild: Vec<String>,
237}
238impl Default for BuildRust {
239 fn default() -> Self {
240 Self {
241 feature_multibuild: vec!["client".to_string(), "server".to_string()],
242 }
243 }
244}
245
246#[derive(Deserialize, Clone, Debug, PartialEq, Serialize)]
247pub struct Dependency {
248 #[serde(default)]
249 pub path: Option<PathBuf>,
250 #[serde(default)]
251 pub deployment: Option<String>,
252 #[serde(default)]
253 pub enabled: Option<bool>,
254}
255impl Dependency {
256 pub fn has_remote_dependency(&self) -> bool {
257 self.deployment.is_some()
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use std::path::PathBuf;
264
265 use indexmap::IndexMap;
266
267 use crate::{
268 Build, BuildRust, Component, ComponentType, Components, Concept, ConceptValue,
269 ContainerType, Dependency, Enum, Identifier, ItemPathBuf, Manifest, ManifestParseError,
270 Package, PackageId, PascalCaseIdentifier, SnakeCaseIdentifier,
271 };
272 use semver::Version;
273
274 fn i(s: &str) -> Identifier {
275 Identifier::new(s).unwrap()
276 }
277
278 fn sci(s: &str) -> SnakeCaseIdentifier {
279 SnakeCaseIdentifier::new(s).unwrap()
280 }
281
282 fn pci(s: &str) -> PascalCaseIdentifier {
283 PascalCaseIdentifier::new(s).unwrap()
284 }
285
286 fn ipb(s: &str) -> ItemPathBuf {
287 ItemPathBuf::new(s).unwrap()
288 }
289
290 #[test]
291 fn can_parse_minimal_toml() {
292 const TOML: &str = r#"
293 [package]
294 id = "lktsfudbjw2qikhyumt573ozxhadkiwm"
295 name = "Test"
296 version = "0.0.1"
297 content = { type = "Playable" }
298 "#;
299
300 assert_eq!(
301 Manifest::parse(TOML),
302 Ok(Manifest {
303 package: Package {
304 id: Some(PackageId("lktsfudbjw2qikhyumt573ozxhadkiwm".to_string())),
305 name: "Test".to_string(),
306 version: Version::parse("0.0.1").unwrap(),
307 ..Default::default()
308 },
309 ..Default::default()
310 })
311 );
312 }
313
314 #[test]
315 fn will_fail_on_legacy_project_toml() {
316 const TOML: &str = r#"
317 [project]
318 id = "lktsfudbjw2qikhyumt573ozxhadkiwm"
319 name = "Test"
320 version = "0.0.1"
321 "#;
322
323 assert_eq!(
324 Manifest::parse(TOML),
325 Err(ManifestParseError::ProjectEmberRenamedToPackageError)
326 )
327 }
328
329 #[test]
330 fn can_parse_tictactoe_toml() {
331 const TOML: &str = r#"
332 [package]
333 id = "lktsfudbjw2qikhyumt573ozxhadkiwm"
334 name = "Tic Tac Toe"
335 version = "0.0.1"
336 content = { type = "Playable" }
337
338 [components]
339 cell = { type = "i32", name = "Cell", description = "The ID of the cell this player is in", attributes = ["store"] }
340
341 [concepts.Cell]
342 name = "Cell"
343 description = "A cell object"
344 [concepts.Cell.components.required]
345 cell = {}
346 "#;
347
348 assert_eq!(
349 Manifest::parse(TOML),
350 Ok(Manifest {
351 package: Package {
352 id: Some(PackageId("lktsfudbjw2qikhyumt573ozxhadkiwm".to_string())),
353 name: "Tic Tac Toe".to_string(),
354 version: Version::parse("0.0.1").unwrap(),
355 ..Default::default()
356 },
357 build: Build {
358 rust: BuildRust {
359 feature_multibuild: vec!["client".to_string(), "server".to_string()]
360 }
361 },
362 components: IndexMap::from_iter([(
363 ipb("cell"),
364 Component {
365 name: Some("Cell".to_string()),
366 description: Some("The ID of the cell this player is in".to_string()),
367 type_: ComponentType::Item(i("i32").into()),
368 attributes: vec![i("store").into()],
369 default: None,
370 }
371 )]),
372 concepts: IndexMap::from_iter([(
373 ipb("Cell"),
374 Concept {
375 name: Some("Cell".to_string()),
376 description: Some("A cell object".to_string()),
377 extends: vec![],
378 components: Components {
379 required: IndexMap::from_iter([(ipb("cell"), ConceptValue::default())]),
380 optional: Default::default()
381 }
382 }
383 )]),
384 messages: Default::default(),
385 enums: Default::default(),
386 includes: Default::default(),
387 dependencies: Default::default(),
388 })
389 )
390 }
391
392 #[test]
393 fn can_parse_rust_build_settings() {
394 const TOML: &str = r#"
395 [package]
396 id = "lktsfudbjw2qikhyumt573ozxhadkiwm"
397 name = "Tic Tac Toe"
398 version = "0.0.1"
399 content = { type = "Playable" }
400 ambient_version = "0.3.0-nightly-2023-08-31"
401
402 [build.rust]
403 feature-multibuild = ["client"]
404 "#;
405
406 assert_eq!(
407 Manifest::parse(TOML),
408 Ok(Manifest {
409 package: Package {
410 id: Some(PackageId("lktsfudbjw2qikhyumt573ozxhadkiwm".to_string())),
411 name: "Tic Tac Toe".to_string(),
412 version: Version::parse("0.0.1").unwrap(),
413 ambient_version: Some(
414 semver::VersionReq::parse("0.3.0-nightly-2023-08-31").unwrap()
415 ),
416 ..Default::default()
417 },
418 build: Build {
419 rust: BuildRust {
420 feature_multibuild: vec!["client".to_string()]
421 }
422 },
423 ..Default::default()
424 })
425 )
426 }
427
428 #[test]
429 fn can_parse_concepts_with_documented_namespace_from_manifest() {
430 use toml::Value;
431
432 const TOML: &str = r#"
433 [package]
434 id = "lktsfudbjw2qikhyumt573ozxhadkiwm"
435 name = "My Package"
436 version = "0.0.1"
437 content = { type = "Playable" }
438
439 [components]
440 "core::transform::rotation" = { type = "quat", name = "Rotation", description = "" }
441 "core::transform::scale" = { type = "vec3", name = "Scale", description = "" }
442 "core::transform::spherical_billboard" = { type = "empty", name = "Spherical billboard", description = "" }
443 "core::transform::translation" = { type = "vec3", name = "Translation", description = "" }
444
445 [concepts."ns::Transformable"]
446 name = "Transformable"
447 description = "Can be translated, rotated and scaled."
448
449 [concepts."ns::Transformable".components.required]
450 # This is intentionally out of order to ensure that order is preserved
451 "core::transform::translation" = { suggested = [0, 0, 0] }
452 "core::transform::scale" = { suggested = [1, 1, 1] }
453 "core::transform::rotation" = { suggested = [0, 0, 0, 1] }
454
455 [concepts."ns::Transformable".components.optional]
456 "core::transform::inv_local_to_world" = { description = "If specified, will be automatically updated" }
457 "#;
458
459 let manifest = Manifest::parse(TOML).unwrap();
460 assert_eq!(
461 manifest,
462 Manifest {
463 package: Package {
464 id: Some(PackageId("lktsfudbjw2qikhyumt573ozxhadkiwm".to_string())),
465 name: "My Package".to_string(),
466 version: Version::parse("0.0.1").unwrap(),
467 ..Default::default()
468 },
469 build: Build {
470 rust: BuildRust {
471 feature_multibuild: vec!["client".to_string(), "server".to_string()]
472 }
473 },
474 components: IndexMap::from_iter([
475 (
476 ipb("core::transform::rotation"),
477 Component {
478 name: Some("Rotation".to_string()),
479 description: Some("".to_string()),
480 type_: ComponentType::Item(i("quat").into()),
481 attributes: vec![],
482 default: None,
483 }
484 ),
485 (
486 ipb("core::transform::scale"),
487 Component {
488 name: Some("Scale".to_string()),
489 description: Some("".to_string()),
490 type_: ComponentType::Item(i("vec3").into()),
491 attributes: vec![],
492 default: None,
493 }
494 ),
495 (
496 ipb("core::transform::spherical_billboard"),
497 Component {
498 name: Some("Spherical billboard".to_string()),
499 description: Some("".to_string()),
500 type_: ComponentType::Item(i("empty").into()),
501 attributes: vec![],
502 default: None,
503 }
504 ),
505 (
506 ipb("core::transform::translation"),
507 Component {
508 name: Some("Translation".to_string()),
509 description: Some("".to_string()),
510 type_: ComponentType::Item(i("vec3").into()),
511 attributes: vec![],
512 default: None,
513 }
514 ),
515 ]),
516 concepts: IndexMap::from_iter([(
517 ipb("ns::Transformable"),
518 Concept {
519 name: Some("Transformable".to_string()),
520 description: Some("Can be translated, rotated and scaled.".to_string()),
521 extends: vec![],
522 components: Components {
523 required: IndexMap::from_iter([
524 (
525 ipb("core::transform::translation"),
526 ConceptValue {
527 suggested: Some(Value::Array(vec![
528 Value::Integer(0),
529 Value::Integer(0),
530 Value::Integer(0)
531 ])),
532 ..Default::default()
533 }
534 ),
535 (
536 ipb("core::transform::scale"),
537 ConceptValue {
538 suggested: Some(Value::Array(vec![
539 Value::Integer(1),
540 Value::Integer(1),
541 Value::Integer(1)
542 ])),
543 ..Default::default()
544 }
545 ),
546 (
547 ipb("core::transform::rotation"),
548 ConceptValue {
549 suggested: Some(Value::Array(vec![
550 Value::Integer(0),
551 Value::Integer(0),
552 Value::Integer(0),
553 Value::Integer(1)
554 ])),
555 ..Default::default()
556 }
557 ),
558 ]),
559 optional: IndexMap::from_iter([(
560 ipb("core::transform::inv_local_to_world"),
561 ConceptValue {
562 description: Some(
563 "If specified, will be automatically updated".to_string()
564 ),
565 ..Default::default()
566 },
567 )])
568 }
569 }
570 )]),
571 messages: Default::default(),
572 enums: Default::default(),
573 includes: Default::default(),
574 dependencies: Default::default(),
575 }
576 );
577
578 assert_eq!(
579 manifest
580 .concepts
581 .first()
582 .unwrap()
583 .1
584 .components
585 .required
586 .keys()
587 .collect::<Vec<_>>(),
588 vec![
589 &ipb("core::transform::translation"),
590 &ipb("core::transform::scale"),
591 &ipb("core::transform::rotation"),
592 ]
593 );
594 }
595
596 #[test]
597 fn can_parse_enums() {
598 const TOML: &str = r#"
599 [package]
600 id = "lktsfudbjw2qikhyumt573ozxhadkiwm"
601 name = "Tic Tac Toe"
602 version = "0.0.1"
603 content = { type = "Playable" }
604
605 [enums.CellState]
606 description = "The current cell state"
607 [enums.CellState.members]
608 Taken = "The cell is taken"
609 Free = "The cell is free"
610 "#;
611
612 assert_eq!(
613 Manifest::parse(TOML),
614 Ok(Manifest {
615 package: Package {
616 id: Some(PackageId("lktsfudbjw2qikhyumt573ozxhadkiwm".to_string())),
617 name: "Tic Tac Toe".to_string(),
618 version: Version::parse("0.0.1").unwrap(),
619 ..Default::default()
620 },
621 build: Build::default(),
622 components: Default::default(),
623 concepts: Default::default(),
624 messages: Default::default(),
625 enums: IndexMap::from_iter([(
626 pci("CellState"),
627 Enum {
628 description: Some("The current cell state".to_string()),
629 members: IndexMap::from_iter([
630 (pci("Taken"), "The cell is taken".to_string()),
631 (pci("Free"), "The cell is free".to_string()),
632 ])
633 }
634 )]),
635 includes: Default::default(),
636 dependencies: Default::default(),
637 })
638 )
639 }
640
641 #[test]
642 fn can_parse_container_types() {
643 const TOML: &str = r#"
644 [package]
645 id = "lktsfudbjw2qikhyumt573ozxhadkiwm"
646 name = "Test"
647 version = "0.0.1"
648 content = { type = "Playable" }
649
650 [components]
651 test = { type = "I32", name = "Test", description = "Test" }
652 vec_test = { type = { container_type = "Vec", element_type = "I32" }, name = "Test", description = "Test" }
653 option_test = { type = { container_type = "Option", element_type = "I32" }, name = "Test", description = "Test" }
654
655 "#;
656
657 assert_eq!(
658 Manifest::parse(TOML),
659 Ok(Manifest {
660 package: Package {
661 id: Some(PackageId("lktsfudbjw2qikhyumt573ozxhadkiwm".to_string())),
662 name: "Test".to_string(),
663 version: Version::parse("0.0.1").unwrap(),
664 ..Default::default()
665 },
666 build: Build {
667 rust: BuildRust {
668 feature_multibuild: vec!["client".to_string(), "server".to_string()]
669 }
670 },
671 components: IndexMap::from_iter([
672 (
673 ipb("test"),
674 Component {
675 name: Some("Test".to_string()),
676 description: Some("Test".to_string()),
677 type_: ComponentType::Item(i("I32").into()),
678 attributes: vec![],
679 default: None,
680 }
681 ),
682 (
683 ipb("vec_test"),
684 Component {
685 name: Some("Test".to_string()),
686 description: Some("Test".to_string()),
687 type_: ComponentType::Contained {
688 type_: ContainerType::Vec,
689 element_type: i("I32").into()
690 },
691 attributes: vec![],
692 default: None,
693 }
694 ),
695 (
696 ipb("option_test"),
697 Component {
698 name: Some("Test".to_string()),
699 description: Some("Test".to_string()),
700 type_: ComponentType::Contained {
701 type_: ContainerType::Option,
702 element_type: i("I32").into()
703 },
704 attributes: vec![],
705 default: None,
706 }
707 )
708 ]),
709 concepts: Default::default(),
710 messages: Default::default(),
711 enums: Default::default(),
712 includes: Default::default(),
713 dependencies: Default::default(),
714 })
715 )
716 }
717
718 #[test]
719 fn can_parse_dependencies() {
720 const TOML: &str = r#"
721 [package]
722 id = "lktsfudbjw2qikhyumt573ozxhadkiwm"
723 name = "dependencies"
724 version = "0.0.1"
725 content = { type = "Playable" }
726
727 [dependencies]
728 deps_assets = { path = "deps/assets" }
729 deps_code = { path = "deps/code" }
730 deps_ignore_me = { path = "deps/ignore_me", enabled = false }
731 deps_remote_deployment = { deployment = "jhsdfu574S" }
732
733 "#;
734
735 assert_eq!(
736 Manifest::parse(TOML),
737 Ok(Manifest {
738 package: Package {
739 id: Some(PackageId("lktsfudbjw2qikhyumt573ozxhadkiwm".to_string())),
740 name: "dependencies".to_string(),
741 version: Version::parse("0.0.1").unwrap(),
742 ..Default::default()
743 },
744 build: Default::default(),
745 components: Default::default(),
746 concepts: Default::default(),
747 messages: Default::default(),
748 enums: Default::default(),
749 includes: Default::default(),
750 dependencies: IndexMap::from_iter([
751 (
752 sci("deps_assets"),
753 Dependency {
754 path: Some(PathBuf::from("deps/assets")),
755 deployment: None,
756 enabled: None,
757 }
758 ),
759 (
760 sci("deps_code"),
761 Dependency {
762 path: Some(PathBuf::from("deps/code")),
763 deployment: None,
764 enabled: None,
765 }
766 ),
767 (
768 sci("deps_ignore_me"),
769 Dependency {
770 path: Some(PathBuf::from("deps/ignore_me")),
771 deployment: None,
772 enabled: Some(false),
773 }
774 ),
775 (
776 sci("deps_remote_deployment"),
777 Dependency {
778 path: None,
779 deployment: Some("jhsdfu574S".to_owned()),
780 enabled: None,
781 }
782 )
783 ])
784 })
785 )
786 }
787}