Skip to main content

shuyi_cli/
project.rs

1//! The project file, `shuyi.ron`, at a game's root: who the game is (its
2//! `app` part), which paths each check scans, the game's tool program (for the commands that run the game),
3//! the font atlas's rules, the web build's package, the game's own license
4//! texts and the private repository's settings, and the targets the game
5//! ships on. A command
6//! whose part is missing says so.
7//!
8//! Every path is relative to the project's root (the folder holding
9//! `shuyi.ron`) and written with `/`.
10
11use std::collections::BTreeMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use serde::Deserialize;
16
17use crate::contract::codes::{self, Code};
18
19/// The project file's name.
20pub const FILE: &str = "shuyi.ron";
21
22/// A project: its root and what its `shuyi.ron` says. Build it with
23/// [`Project::new`] or [`Project::load`] (ADR-0039).
24#[derive(Debug, Clone, PartialEq)]
25#[non_exhaustive]
26pub struct Project {
27    /// The folder holding `shuyi.ron`.
28    pub root: PathBuf,
29    /// What it says.
30    pub config: Config,
31}
32
33impl Project {
34    /// The project at `root` whose `shuyi.ron` says `config`.
35    pub fn new(root: impl Into<PathBuf>, config: Config) -> Self {
36        Self {
37            root: root.into(),
38            config,
39        }
40    }
41
42    /// The project whose `shuyi.ron` is in `root`.
43    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    /// The project of the first folder from `start` up that holds a
54    /// `shuyi.ron`, or the problem code and the message why there is
55    /// none: no file ([`codes::NO_PROJECT`]), or one that doesn't read
56    /// ([`codes::PROJECT_FILE`]).
57    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    /// The part `part` of the config, named `name`, or the error that
71    /// names it as missing (a [`codes::PROJECT_FILE`] problem).
72    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/// What `shuyi.ron` says.
79#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
80#[serde(deny_unknown_fields, default)]
81// Parsed, never built by a literal outside this crate: it may gain parts
82// (ADR-0039).
83#[non_exhaustive]
84pub struct Config {
85    /// What the project calls this tool in what it prints (`usage: …`,
86    /// generated files): `shuyi` unless it says otherwise.
87    pub command: Option<String>,
88    /// Who the game is: its name, id, version and storage's name, which
89    /// `check --target` checks and `app-info` writes as Rust
90    /// ([`crate::app_info`]).
91    pub app: Option<AppPart>,
92    /// The game's tool program, for `frame-png` and `lang-status`.
93    pub game: Option<GameProgram>,
94    /// `check-keys`.
95    pub check_keys: Option<CheckKeys>,
96    /// `check-text`.
97    pub check_text: Option<CheckText>,
98    /// The game's rules package, which `lint` checks is fenced: clippy's
99    /// bans and no screen, device or tool crate under it (ADR-0032 §4).
100    pub rules: Option<String>,
101    /// `font-atlas`.
102    pub font_atlas: Option<FontAtlas>,
103    /// `web`.
104    pub web: Option<Web>,
105    /// The game's own license texts, which its packages carry.
106    pub notices: Option<Notices>,
107    /// What `export` turns on and ships beside the program.
108    pub export: Option<ExportPart>,
109    /// What the game's packages may hold, which `export` holds each final
110    /// package to ([`crate::export::disclosure`]).
111    pub disclosure: Option<Disclosure>,
112    /// `glyph-tiles`.
113    pub glyph_tiles: Option<GlyphTiles>,
114    /// `private-assets`.
115    pub private_assets: Option<PrivateAssets>,
116    /// `check --target`: the targets the game ships on, by the id of
117    /// their profile (ADR-0008 §1), and how it shows its frame on each.
118    /// It lists only those.
119    pub targets: BTreeMap<String, TargetSetting>,
120    /// The folder of the game's own target profiles, if it has any: each
121    /// adds a target, or replaces the engine's of its id.
122    pub target_profiles: Option<String>,
123    /// The folder of the game's files, which `run --watch` lays over the
124    /// embedded ones: `assets` unless it says.
125    pub assets: Option<String>,
126}
127
128impl Config {
129    /// Parses the text of a `shuyi.ron`. A part is written as it is, not
130    /// in `Some(…)` (RON's `implicit_some` is on).
131    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    /// The tool's name in what it prints.
139    pub fn command(&self) -> &str {
140        self.command.as_deref().unwrap_or("shuyi")
141    }
142}
143
144/// Who the game is, the same on every target ([`crate::app_info`],
145/// ADR-0059).
146#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
147#[serde(deny_unknown_fields)]
148#[non_exhaustive]
149pub struct AppPart {
150    /// The name players see.
151    pub name: String,
152    /// Its reverse-DNS id, unique to the game: `com.example.moon-garden`.
153    pub id: String,
154    /// Its version, semver; without it, its crate's
155    /// (`CARGO_PKG_VERSION`).
156    #[serde(default)]
157    pub version: Option<String>,
158    /// A number that only grows from one build handed out to the next.
159    #[serde(default = "first_build")]
160    pub build: u32,
161    /// Who publishes it.
162    #[serde(default)]
163    pub publisher: String,
164    /// The name its saves are kept under; without it, the id.
165    #[serde(default)]
166    pub storage: Option<String>,
167    /// Where `app-info` writes the Rust file (`crates/game/src/app_info.rs`);
168    /// without it, no Rust is written.
169    #[serde(default)]
170    pub code: Option<String>,
171    /// The game's icon: one square PNG, 512 pixels a side or more, which
172    /// the tools downscale to every size a target asks (ADR-0061).
173    #[serde(default)]
174    pub icon: Option<String>,
175    /// The folder where `app-info` writes the window's icons, `16.png`,
176    /// `32.png` and `64.png` (`crates/game/icon`); `code` reads them.
177    #[serde(default)]
178    pub window_icons: Option<String>,
179    /// The window's icons drawn by hand: a side in pixels and the square
180    /// PNG of that side to use for it, in place of downscaling `icon`
181    /// (ADR-0069). `icon` still makes the sides this doesn't name.
182    #[serde(default)]
183    pub icon_sizes: BTreeMap<u32, String>,
184    /// The Playdate launcher's list icon: a 32 × 32 black and white PNG, in
185    /// place of a one-bit bake of `icon`.
186    #[serde(default)]
187    pub playdate_icon: Option<String>,
188    /// The Playdate launcher's card: a 350 × 155 black and white PNG, in
189    /// place of a one-bit bake of `icon`.
190    #[serde(default)]
191    pub playdate_card: Option<String>,
192}
193
194/// A game's first build.
195const fn first_build() -> u32 {
196    1
197}
198
199impl AppPart {
200    /// The game called `name` with the id `id`, and every other field at
201    /// its default.
202    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    /// The name its saves are kept under: its `storage`, else its id.
220    pub fn storage(&self) -> &str {
221        self.storage.as_deref().unwrap_or(&self.id)
222    }
223}
224
225/// How a game shows its frame on one target.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct TargetSetting {
229    /// Its frame's width and height, in logical pixels.
230    pub frame: (u16, u16),
231    /// How the frame is scaled to the display.
232    pub scale: Scale,
233    /// The height of the smallest text on screen, in logical pixels: the
234    /// font's cell height.
235    pub text_px: u16,
236}
237
238/// How a frame is scaled to a display.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum Scale {
242    /// By the largest whole number that fits: pixels stay square and
243    /// sharp.
244    Integer,
245    /// By the largest factor that fits, whole or not, smaller than 1 too.
246    Fit,
247}
248
249/// The game's tool program: a program of the game that runs
250/// [`crate::main_with`] with the game's [`crate::GameTools`]. `shuyi`
251/// runs it with `cargo run` for the commands that need the game.
252#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
253#[serde(deny_unknown_fields, default)]
254pub struct GameProgram {
255    /// Its package.
256    pub package: String,
257    /// Its binary, if the package has more than one.
258    pub bin: Option<String>,
259    /// Or its example.
260    pub example: Option<String>,
261    /// Features to build it with.
262    pub features: Vec<String>,
263    /// Arguments before the command's.
264    pub args: Vec<String>,
265}
266
267impl GameProgram {
268    /// The `cargo` arguments that run `command` with `args` in it.
269    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    /// The `cargo` arguments that build it as [`Self::cargo_args`] runs
279    /// it, so that run has nothing left to compile.
280    pub fn cargo_build_args(&self) -> Vec<String> {
281        self.selected(&["build"])
282    }
283
284    /// `first`, then the arguments that pick the program and its
285    /// features.
286    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/// What `check-keys` scans and lets through.
303#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
304#[serde(deny_unknown_fields, default)]
305pub struct CheckKeys {
306    /// Rust source folders scanned (tests left out).
307    pub rust: Vec<String>,
308    /// Asset folders whose `.ron` and `.dlg` text is scanned.
309    pub assets: Vec<String>,
310    /// Asset files that are no player text: the keymap file, which names
311    /// keys by design.
312    pub not_text: Vec<String>,
313    /// The key pipeline: files that may name key and button types and key
314    /// names.
315    pub key_files: Vec<String>,
316    /// The only file that may read the keyboard (`KeyCode`, `is_key_down`,
317    /// …).
318    pub platform_file: String,
319    /// Files only compiled for tests (besides `tests.rs`, `*_tests.rs` and
320    /// `tests/`).
321    pub test_support: Vec<String>,
322    /// Files where a comment marking a keyboard picture (the text of
323    /// `check_keys::MARKER`) is honoured.
324    pub picture_files: Vec<String>,
325    /// What every error says to do about it.
326    pub hint: String,
327}
328
329/// What `check-text` scans and lets through.
330#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
331#[serde(deny_unknown_fields, default)]
332pub struct CheckText {
333    /// The screens' Rust source folder.
334    pub dir: String,
335    /// The most player-facing literals allowed.
336    pub max_literals: usize,
337    /// Where the data's names are read on purpose: the helpers every
338    /// screen uses.
339    pub words_file: String,
340    /// Files left out besides tests (a path ending in `/` leaves out a
341    /// folder): the debug screens, a frame's text form for snapshots.
342    pub skip: Vec<String>,
343    /// Files only compiled for tests (besides `tests.rs`, `*_tests.rs` and
344    /// `tests/`).
345    pub test_support: Vec<String>,
346    /// What a literal is told to do about it.
347    pub hint: String,
348    /// What a `.name` read is told to do about it.
349    pub name_hint: String,
350}
351
352/// `font-atlas`'s rules.
353#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
354#[serde(deny_unknown_fields, default)]
355pub struct FontAtlas {
356    /// The glyphs an atlas must have, in any order.
357    pub required_glyphs: String,
358    /// The README the generated `atlas.ron` points at, if any.
359    pub readme: Option<String>,
360    /// The glyphs `font-atlas --wide` keeps from the wide font whatever
361    /// the text uses (a name entry's letters), in any order: empty by
362    /// default.
363    pub wide_glyphs: String,
364    /// The glyphs `font-atlas --wide <set>=<font.bdf>` keeps in each named
365    /// wide set whatever the text uses, by set name: empty by default.
366    pub wide_set_glyphs: BTreeMap<String, String>,
367}
368
369/// `glyph-tiles`: a tileset of glyphs on colored squares
370/// ([`crate::glyph_tiles`]).
371#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
372#[serde(deny_unknown_fields, default)]
373pub struct GlyphTiles {
374    /// The folder holding the font atlas (`atlas.png` and `atlas.ron`).
375    pub font: String,
376    /// The palette file whose colors the tiles name.
377    pub palette: String,
378    /// Where the PNG goes.
379    pub out: String,
380    /// A tile's side, in pixels.
381    pub tile: u32,
382    /// Tiles per row of the picture.
383    pub columns: u32,
384    /// The tiles, in order.
385    pub tiles: Vec<GlyphTile>,
386}
387
388/// One tile of a [`GlyphTiles`].
389#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
390#[serde(deny_unknown_fields)]
391pub struct GlyphTile {
392    /// Its glyphs, side by side and centred.
393    pub glyphs: String,
394    /// The glyphs' color, by its palette name.
395    pub fg: String,
396    /// The square's color, by its palette name; `None` leaves it clear.
397    #[serde(default)]
398    pub bg: Option<String>,
399}
400
401/// The web build.
402#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
403#[serde(deny_unknown_fields, default)]
404pub struct Web {
405    /// The game's binary's package.
406    pub package: String,
407    /// Its binary, whose `.wasm` the page loads.
408    pub bin: String,
409    /// The page's title.
410    pub title: String,
411    /// The folder of music tracks copied beside the page (`.ogg` only),
412    /// if the game has one.
413    pub music: Option<String>,
414    /// The folder of voice clips copied beside the page (`.ogg` and
415    /// `.ron`), if the game has one.
416    pub voice: Option<String>,
417    /// The features of the package a `--<feature>` flag turns on.
418    pub features: Vec<String>,
419    /// Where the page goes.
420    pub dist: String,
421}
422
423/// The game's own license texts (its fonts', say): each file goes whole
424/// into the `NOTICES.txt` of every package `shuyi` makes
425/// ([`crate::notices`]).
426#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
427#[serde(deny_unknown_fields, default)]
428#[non_exhaustive]
429pub struct Notices {
430    /// The files, from the project's root.
431    pub texts: Vec<String>,
432}
433
434/// What `export` builds and ships for the desktop and web targets, on top of
435/// the program and its notes. The Playdate's package, a `.pdx`, takes none of
436/// it.
437#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
438#[serde(deny_unknown_fields, default)]
439#[non_exhaustive]
440pub struct ExportPart {
441    /// The package whose program ships, when it isn't the `game` part's (a
442    /// game whose tool program is a package of its own).
443    pub package: Option<String>,
444    /// The features of the game's package a build turns on. `link`, the
445    /// debug build's live link, is refused.
446    pub features: Vec<String>,
447    /// The files and folders copied into the package.
448    pub beside: Vec<Beside>,
449}
450
451/// A file or folder of the game that ships in its package.
452#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
453#[serde(deny_unknown_fields)]
454#[non_exhaustive]
455pub struct Beside {
456    /// Where it is, from the project's root.
457    pub path: String,
458    /// Its name in the package; without it, the one it has.
459    #[serde(default)]
460    pub name: Option<String>,
461    /// Whether the package goes without it when it isn't there; otherwise
462    /// that refuses the export.
463    #[serde(default)]
464    pub optional: bool,
465}
466
467/// What the game's packages may hold, the same for every target: a path is
468/// matched whole, with no pattern, from the package's root or the pack's.
469/// What the exporter itself writes (the program, `NOTICES.txt`, the web
470/// shell, the pack's file) needs no line here.
471#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
472#[serde(deny_unknown_fields, default)]
473#[non_exhaustive]
474pub struct Disclosure {
475    /// The files of the game a package may hold beside what the exporter
476    /// writes (`music/title.ogg`, `audio/step.pda`).
477    pub allowed_files: Vec<String>,
478    /// The files a pack may hold (`maps/first.ron`).
479    pub allowed_pack_entries: Vec<String>,
480    /// The paths nothing may hold: no file of the package or beside it, no
481    /// file of a pack, and no program as text (a file a game embeds keeps
482    /// its path in the program).
483    pub forbidden_paths: Vec<String>,
484    /// The texts nothing may hold, each written in a file of what isn't out
485    /// yet so that a copy of it can be found: UTF-8 or UTF-16, whole, in any
486    /// file of the package, of a pack or beside it.
487    pub canaries: Vec<String>,
488}
489
490/// The private repository of files the game may not hand out (bought art):
491/// checked out into a git-ignored folder at a pinned commit.
492#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
493#[serde(deny_unknown_fields, default)]
494pub struct PrivateAssets {
495    /// The repository.
496    pub url: String,
497    /// Where it is checked out, in the project's root.
498    pub checkout: String,
499    /// The file in the project's root that names the commit to build with.
500    pub pin_file: String,
501    /// The folders of the repository a checkout made here holds (the first
502    /// is the one a new clone starts with).
503    pub folders: Vec<String>,
504    /// The folder `--library` brings too, if any.
505    pub library: Option<String>,
506    /// Folders copied out of the checkout into the project's root (each
507    /// replaced whole), after a fetch.
508    pub copies: Vec<CopyOut>,
509    /// What `--pin` checks has a credit, if anything.
510    pub credits: Option<Credited>,
511    /// What a refused clone or fetch says about who may read the
512    /// repository.
513    pub no_access: String,
514}
515
516/// The files of the private checkout that need a credit before `--pin`
517/// writes the pin.
518#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
519#[serde(deny_unknown_fields, default)]
520pub struct Credited {
521    /// The credits file, in the project's root.
522    pub file: String,
523    /// The folders of the checkout whose files need a credit.
524    pub folders: Vec<String>,
525    /// The audio manifest, as the credits file's errors name it.
526    pub audio_manifest: String,
527    /// The folder a credit's `private` paths are in, as its errors name
528    /// it.
529    pub private_root: String,
530    /// What else an uncredited file's message says to do, if anything.
531    pub hint: Option<String>,
532}
533
534/// A folder copied out of the private checkout.
535#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
536#[serde(deny_unknown_fields)]
537pub struct CopyOut {
538    /// The folder in the checkout, and what its files are called.
539    pub from: String,
540    /// Where it goes in the project's root.
541    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        // Built as it is run: the same package, program and features.
721        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}