1use std::collections::BTreeMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use serde::Deserialize;
16
17use crate::contract::codes::{self, Code};
18
19pub const FILE: &str = "shuyi.ron";
21
22#[derive(Debug, Clone, PartialEq)]
25#[non_exhaustive]
26pub struct Project {
27 pub root: PathBuf,
29 pub config: Config,
31}
32
33impl Project {
34 pub fn new(root: impl Into<PathBuf>, config: Config) -> Self {
36 Self {
37 root: root.into(),
38 config,
39 }
40 }
41
42 pub fn load(root: &Path) -> Result<Self, String> {
44 let path = root.join(FILE);
45 let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
46 let config = Config::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?;
47 Ok(Self {
48 root: root.to_path_buf(),
49 config,
50 })
51 }
52
53 pub fn find(start: &Path) -> Result<Self, (Code, String)> {
58 let root = start
59 .ancestors()
60 .find(|dir| dir.join(FILE).is_file())
61 .ok_or_else(|| {
62 (
63 codes::NO_PROJECT,
64 format!("no {FILE} in {} or a folder above it", start.display()),
65 )
66 })?;
67 Self::load(root).map_err(|e| (codes::PROJECT_FILE, e))
68 }
69
70 pub fn part<'a, T>(&self, part: &'a Option<T>, name: &str) -> Result<&'a T, String> {
73 part.as_ref()
74 .ok_or_else(|| format!("{FILE} has no `{name}`"))
75 }
76}
77
78#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
80#[serde(deny_unknown_fields, default)]
81#[non_exhaustive]
84pub struct Config {
85 pub command: Option<String>,
88 pub app: Option<AppPart>,
92 pub game: Option<GameProgram>,
94 pub check_keys: Option<CheckKeys>,
96 pub check_text: Option<CheckText>,
98 pub rules: Option<String>,
101 pub font_atlas: Option<FontAtlas>,
103 pub web: Option<Web>,
105 pub notices: Option<Notices>,
107 pub export: Option<ExportPart>,
109 pub disclosure: Option<Disclosure>,
112 pub glyph_tiles: Option<GlyphTiles>,
114 pub private_assets: Option<PrivateAssets>,
116 pub targets: BTreeMap<String, TargetSetting>,
120 pub target_profiles: Option<String>,
123 pub assets: Option<String>,
126}
127
128impl Config {
129 pub fn parse(text: &str) -> Result<Self, String> {
132 ron::Options::default()
133 .with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME)
134 .from_str(text)
135 .map_err(|e| e.to_string())
136 }
137
138 pub fn command(&self) -> &str {
140 self.command.as_deref().unwrap_or("shuyi")
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
147#[serde(deny_unknown_fields)]
148#[non_exhaustive]
149pub struct AppPart {
150 pub name: String,
152 pub id: String,
154 #[serde(default)]
157 pub version: Option<String>,
158 #[serde(default = "first_build")]
160 pub build: u32,
161 #[serde(default)]
163 pub publisher: String,
164 #[serde(default)]
166 pub storage: Option<String>,
167 #[serde(default)]
170 pub code: Option<String>,
171 #[serde(default)]
174 pub icon: Option<String>,
175 #[serde(default)]
178 pub window_icons: Option<String>,
179 #[serde(default)]
183 pub icon_sizes: BTreeMap<u32, String>,
184 #[serde(default)]
187 pub playdate_icon: Option<String>,
188 #[serde(default)]
191 pub playdate_card: Option<String>,
192}
193
194const fn first_build() -> u32 {
196 1
197}
198
199impl AppPart {
200 pub fn new(name: impl Into<String>, id: impl Into<String>) -> Self {
203 Self {
204 name: name.into(),
205 id: id.into(),
206 version: None,
207 build: first_build(),
208 publisher: String::new(),
209 storage: None,
210 code: None,
211 icon: None,
212 window_icons: None,
213 icon_sizes: BTreeMap::new(),
214 playdate_icon: None,
215 playdate_card: None,
216 }
217 }
218
219 pub fn storage(&self) -> &str {
221 self.storage.as_deref().unwrap_or(&self.id)
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct TargetSetting {
229 pub frame: (u16, u16),
231 pub scale: Scale,
233 pub text_px: u16,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum Scale {
242 Integer,
245 Fit,
247}
248
249#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
253#[serde(deny_unknown_fields, default)]
254pub struct GameProgram {
255 pub package: String,
257 pub bin: Option<String>,
259 pub example: Option<String>,
261 pub features: Vec<String>,
263 pub args: Vec<String>,
265}
266
267impl GameProgram {
268 pub fn cargo_args(&self, command: &str, args: &[String]) -> Vec<String> {
270 let mut out = self.selected(&["run", "--quiet"]);
271 out.push("--".to_owned());
272 out.extend(self.args.iter().cloned());
273 out.push(command.to_owned());
274 out.extend(args.iter().cloned());
275 out
276 }
277
278 pub fn cargo_build_args(&self) -> Vec<String> {
281 self.selected(&["build"])
282 }
283
284 fn selected(&self, first: &[&str]) -> Vec<String> {
287 let mut out: Vec<String> = first.iter().map(|s| (*s).to_owned()).collect();
288 out.extend(["-p".to_owned(), self.package.clone()]);
289 if let Some(bin) = &self.bin {
290 out.extend(["--bin".to_owned(), bin.clone()]);
291 }
292 if let Some(example) = &self.example {
293 out.extend(["--example".to_owned(), example.clone()]);
294 }
295 if !self.features.is_empty() {
296 out.extend(["--features".to_owned(), self.features.join(",")]);
297 }
298 out
299 }
300}
301
302#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
304#[serde(deny_unknown_fields, default)]
305pub struct CheckKeys {
306 pub rust: Vec<String>,
308 pub assets: Vec<String>,
310 pub not_text: Vec<String>,
313 pub key_files: Vec<String>,
316 pub platform_file: String,
319 pub test_support: Vec<String>,
322 pub picture_files: Vec<String>,
325 pub hint: String,
327}
328
329#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
331#[serde(deny_unknown_fields, default)]
332pub struct CheckText {
333 pub dir: String,
335 pub max_literals: usize,
337 pub words_file: String,
340 pub skip: Vec<String>,
343 pub test_support: Vec<String>,
346 pub hint: String,
348 pub name_hint: String,
350}
351
352#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
354#[serde(deny_unknown_fields, default)]
355pub struct FontAtlas {
356 pub required_glyphs: String,
358 pub readme: Option<String>,
360 pub wide_glyphs: String,
364 pub wide_set_glyphs: BTreeMap<String, String>,
367}
368
369#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
372#[serde(deny_unknown_fields, default)]
373pub struct GlyphTiles {
374 pub font: String,
376 pub palette: String,
378 pub out: String,
380 pub tile: u32,
382 pub columns: u32,
384 pub tiles: Vec<GlyphTile>,
386}
387
388#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
390#[serde(deny_unknown_fields)]
391pub struct GlyphTile {
392 pub glyphs: String,
394 pub fg: String,
396 #[serde(default)]
398 pub bg: Option<String>,
399}
400
401#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
403#[serde(deny_unknown_fields, default)]
404pub struct Web {
405 pub package: String,
407 pub bin: String,
409 pub title: String,
411 pub music: Option<String>,
414 pub voice: Option<String>,
417 pub features: Vec<String>,
419 pub dist: String,
421}
422
423#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
427#[serde(deny_unknown_fields, default)]
428#[non_exhaustive]
429pub struct Notices {
430 pub texts: Vec<String>,
432}
433
434#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
438#[serde(deny_unknown_fields, default)]
439#[non_exhaustive]
440pub struct ExportPart {
441 pub package: Option<String>,
444 pub features: Vec<String>,
447 pub beside: Vec<Beside>,
449}
450
451#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
453#[serde(deny_unknown_fields)]
454#[non_exhaustive]
455pub struct Beside {
456 pub path: String,
458 #[serde(default)]
460 pub name: Option<String>,
461 #[serde(default)]
464 pub optional: bool,
465}
466
467#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
472#[serde(deny_unknown_fields, default)]
473#[non_exhaustive]
474pub struct Disclosure {
475 pub allowed_files: Vec<String>,
478 pub allowed_pack_entries: Vec<String>,
480 pub forbidden_paths: Vec<String>,
484 pub canaries: Vec<String>,
488}
489
490#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
493#[serde(deny_unknown_fields, default)]
494pub struct PrivateAssets {
495 pub url: String,
497 pub checkout: String,
499 pub pin_file: String,
501 pub folders: Vec<String>,
504 pub library: Option<String>,
506 pub copies: Vec<CopyOut>,
509 pub credits: Option<Credited>,
511 pub no_access: String,
514}
515
516#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
519#[serde(deny_unknown_fields, default)]
520pub struct Credited {
521 pub file: String,
523 pub folders: Vec<String>,
525 pub audio_manifest: String,
527 pub private_root: String,
530 pub hint: Option<String>,
532}
533
534#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
536#[serde(deny_unknown_fields)]
537pub struct CopyOut {
538 pub from: String,
540 pub to: String,
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn an_empty_file_is_a_project_with_no_parts() {
550 let config = Config::parse("()").unwrap();
551 assert_eq!(config, Config::default());
552 assert_eq!(config.command(), "shuyi");
553 }
554
555 #[test]
556 fn parts_are_read_and_unknown_fields_refused() {
557 let text = r#"(
558 command: "cargo shuyi",
559 game: (package: "tools", features: ["a", "b"]),
560 font_atlas: (required_glyphs: "?ab"),
561)"#;
562 let config = Config::parse(text).unwrap();
563 assert_eq!(config.command(), "cargo shuyi");
564 assert_eq!(config.game.as_ref().unwrap().package, "tools");
565 assert_eq!(config.font_atlas.unwrap().required_glyphs, "?ab");
566 assert!(Config::parse("(colour: 1)").is_err());
567 assert!(Config::parse("(web: (name: \"x\"))").is_err());
568 }
569
570 #[test]
571 fn the_app_part_is_read_with_its_defaults_and_unknown_fields_refused() {
572 let config =
573 Config::parse(r#"(app: (name: "Moon Garden", id: "com.example.moon-garden"))"#)
574 .unwrap();
575 let app = config.app.unwrap();
576 assert_eq!(app, AppPart::new("Moon Garden", "com.example.moon-garden"));
577 assert_eq!((app.build, app.publisher.as_str()), (1, ""));
578 assert_eq!((&app.version, &app.code), (&None, &None));
579 assert_eq!(app.storage(), "com.example.moon-garden");
580 assert!(app.icon_sizes.is_empty());
581 let text = r#"(app: (
582 name: "Moon Garden",
583 id: "com.example.moon-garden",
584 version: "1.2.0-beta.1",
585 build: 12,
586 publisher: "Someone",
587 storage: "moon_garden",
588 code: "crates/game/src/app_info.rs",
589 icon: "art/icon.png",
590 window_icons: "crates/game/icon",
591 icon_sizes: { 16: "art/icon-16.png", 32: "art/icon-32.png" },
592))"#;
593 let app = Config::parse(text).unwrap().app.unwrap();
594 assert_eq!(app.icon.as_deref(), Some("art/icon.png"));
595 assert_eq!(
596 app.icon_sizes,
597 BTreeMap::from([
598 (16, "art/icon-16.png".to_owned()),
599 (32, "art/icon-32.png".to_owned()),
600 ])
601 );
602 assert_eq!(app.window_icons.as_deref(), Some("crates/game/icon"));
603 assert_eq!(app.version.as_deref(), Some("1.2.0-beta.1"));
604 assert_eq!((app.build, app.publisher.as_str()), (12, "Someone"));
605 assert_eq!(app.storage(), "moon_garden");
606 assert_eq!(app.code.as_deref(), Some("crates/game/src/app_info.rs"));
607 assert!(Config::parse(r#"(app: (name: "A", id: "a.b", colour: 1))"#).is_err());
608 assert!(
609 Config::parse(r#"(app: (name: "A"))"#).is_err(),
610 "the id is needed"
611 );
612 }
613
614 #[test]
615 fn the_notices_part_lists_the_games_texts() {
616 let config =
617 Config::parse(r#"(notices: (texts: ["files/font/Terminus-LICENSE.txt"]))"#).unwrap();
618 assert_eq!(
619 config.notices.unwrap().texts,
620 ["files/font/Terminus-LICENSE.txt"]
621 );
622 assert_eq!(
623 Config::parse("(notices: ())").unwrap().notices,
624 Some(Notices::default())
625 );
626 assert!(Config::parse(r#"(notices: (files: ["a"]))"#).is_err());
627 }
628
629 #[test]
630 fn the_export_part_names_features_and_what_ships_beside() {
631 let text = r#"(export: (
632 features: ["art"],
633 beside: [
634 (path: "music"),
635 (path: "voice", optional: true),
636 (path: "files/licenses", name: "licenses"),
637 ],
638))"#;
639 let part = Config::parse(text).unwrap().export.unwrap();
640 assert_eq!(part.features, ["art"]);
641 let row = |path: &str, name: Option<&str>, optional| Beside {
642 path: path.to_owned(),
643 name: name.map(str::to_owned),
644 optional,
645 };
646 assert_eq!(
647 part.beside,
648 [
649 row("music", None, false),
650 row("voice", None, true),
651 row("files/licenses", Some("licenses"), false),
652 ]
653 );
654 assert!(Config::parse("(export: (beside: [(optional: true)]))").is_err());
655 assert!(Config::parse("(export: (files: []))").is_err());
656 }
657
658 #[test]
659 fn targets_are_a_table_of_settings_by_id() {
660 let text = r#"(
661 targets: {
662 "desktop": (frame: (800, 512), scale: integer, text_px: 16),
663 "web": (frame: (400, 240), scale: fit, text_px: 8),
664 },
665 target_profiles: "files/targets",
666)"#;
667 let config = Config::parse(text).unwrap();
668 assert_eq!(
669 config.targets["desktop"],
670 TargetSetting {
671 frame: (800, 512),
672 scale: Scale::Integer,
673 text_px: 16,
674 }
675 );
676 assert_eq!(config.targets["web"].scale, Scale::Fit);
677 assert_eq!(config.target_profiles.as_deref(), Some("files/targets"));
678 assert!(
679 Config::parse("(targets: {\"a\": (frame: (1, 1), scale: none, text_px: 1)})").is_err()
680 );
681 }
682
683 #[test]
684 fn the_game_program_runs_through_cargo() {
685 let args =
686 |items: &[&str]| -> Vec<String> { items.iter().map(|s| (*s).to_owned()).collect() };
687 let plain = GameProgram {
688 package: "tools".to_owned(),
689 ..GameProgram::default()
690 };
691 assert_eq!(
692 plain.cargo_args("frame-png", &args(&["a.png"])),
693 args(&["run", "--quiet", "-p", "tools", "--", "frame-png", "a.png"])
694 );
695 let example = GameProgram {
696 package: "engine".to_owned(),
697 bin: Some("b".to_owned()),
698 example: Some("toy".to_owned()),
699 features: args(&["toy", "x"]),
700 args: args(&["--first"]),
701 };
702 assert_eq!(
703 example.cargo_args("lang-status", &[]),
704 args(&[
705 "run",
706 "--quiet",
707 "-p",
708 "engine",
709 "--bin",
710 "b",
711 "--example",
712 "toy",
713 "--features",
714 "toy,x",
715 "--",
716 "--first",
717 "lang-status"
718 ])
719 );
720 assert_eq!(plain.cargo_build_args(), args(&["build", "-p", "tools"]));
722 assert_eq!(
723 example.cargo_build_args(),
724 args(&[
725 "build",
726 "-p",
727 "engine",
728 "--bin",
729 "b",
730 "--example",
731 "toy",
732 "--features",
733 "toy,x"
734 ])
735 );
736 }
737
738 #[test]
739 fn a_project_is_found_above_where_it_starts() {
740 let dir = std::env::temp_dir().join(format!("shuyi-project-{}", std::process::id()));
741 let deep = dir.join("a/b");
742 fs::create_dir_all(&deep).unwrap();
743 let _ = fs::remove_file(dir.join(FILE));
744 fs::write(dir.join(FILE), "(command: \"t\")").unwrap();
745 let project = Project::find(&deep).unwrap();
746 assert_eq!(project.root, dir);
747 assert_eq!(project.config.command(), "t");
748 fs::write(dir.join(FILE), "(nope)").unwrap();
749 let (code, error) = Project::find(&deep).unwrap_err();
750 assert_eq!(code, codes::PROJECT_FILE);
751 assert!(error.contains(FILE), "{error}");
752 let missing = project.part(&project.config.web, "web").unwrap_err();
753 assert_eq!(missing, "shuyi.ron has no `web`");
754 fs::remove_file(dir.join(FILE)).unwrap();
755 let (code, error) = Project::find(&deep).unwrap_err();
756 assert_eq!(code, codes::NO_PROJECT);
757 assert!(error.starts_with("no shuyi.ron in "), "{error}");
758 fs::remove_dir_all(&dir).unwrap();
759 }
760
761 #[test]
762 fn a_new_project_is_its_root_and_config() {
763 let config = Config {
764 command: Some("cargo shuyi".to_owned()),
765 ..Config::default()
766 };
767 let literal = Project {
768 root: PathBuf::from("game"),
769 config: config.clone(),
770 };
771 assert_eq!(Project::new("game", config), literal);
772 }
773}