Skip to main content

shuyi_cli/
app_info.rs

1//! Who the game is: `shuyi.ron`'s `app` part ([`AppPart`], ADR-0059), its
2//! rules ([`problems`], which `check --target` reports), and `app-info`,
3//! which writes it as the Rust constant the game's binary compiles
4//! ([`rust`], [`run`]) and the window's icons, downscaled from the game's
5//! one icon ([`downscale`], ADR-0061) or drawn per size (ADR-0069).
6//!
7//! The rules:
8//!
9//! - `name`: not empty, with no control character;
10//! - `id`: reverse-DNS, two or more parts between dots, each of ASCII
11//!   letters, digits and `-`, starting with a letter; an id under
12//!   `com.example.` is a warning, since two games with it share their
13//!   saves;
14//! - `version`, when given: semver, `MAJOR.MINOR.PATCH` then an optional
15//!   `-pre-release` and `+build`;
16//! - `build`: 1 or more;
17//! - `storage`, or the id when it is unset: one folder name;
18//! - `icon`, when given: a PNG file, square, [`ICON_SIDE`] pixels a side
19//!   or more;
20//! - `window_icons`: set when `icon` and `code` are, since the Rust reads
21//!   the window's icons from there;
22//! - `icon_sizes`: each key one of [`WINDOW_ICON_SIDES`], each file a PNG
23//!   square at its key's side, with `icon` and `window_icons` set, since
24//!   the sizes it doesn't draw come from the icon and it fills that folder;
25//! - the files `app-info` writes (`code` and the window's icons) are the
26//!   ones on disk.
27
28use std::fmt::Write as _;
29use std::fs;
30use std::path::Path;
31
32use crate::codec;
33use crate::contract::target::Level;
34use crate::project::{AppPart, FILE};
35
36/// What `app-info` and the rules call the part.
37const PART: &str = "app";
38
39/// The id's start that marks it as a placeholder.
40pub const PLACEHOLDER: &str = "com.example.";
41
42/// The smallest side of the game's icon, in pixels: every size a target
43/// asks is downscaled from it, never scaled up.
44pub const ICON_SIDE: u32 = 512;
45
46/// The window's icons' sides, in pixels: `WindowIcon`'s small, medium and
47/// big.
48pub const WINDOW_ICON_SIDES: [u32; 3] = [16, 32, 64];
49
50/// A problem of the `app` part.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct AppProblem {
53    /// An error or a warning.
54    pub level: Level,
55    /// The field it is about: `app.id`.
56    pub setting: String,
57    /// What is wrong.
58    pub message: String,
59}
60
61impl AppProblem {
62    fn new(level: Level, field: &str, message: impl Into<String>) -> Self {
63        Self {
64            level,
65            setting: format!("{PART}.{field}"),
66            message: message.into(),
67        }
68    }
69
70    fn error(field: &str, message: impl Into<String>) -> Self {
71        Self::new(Level::Error, field, message)
72    }
73}
74
75/// Every problem of `part`, in the project at `root` whose tool is called
76/// `command`: its fields and its icon by the rules, and each file
77/// `app-info` writes that isn't the one on disk.
78pub fn problems(part: &AppPart, root: &Path, command: &str) -> Vec<AppProblem> {
79    let mut out = checked(part, root);
80    for path in stale(part, root, command) {
81        let field = if is_png(&path) {
82            "window_icons"
83        } else {
84            "code"
85        };
86        out.push(AppProblem::error(
87            field,
88            format!("{path} is out of date: run `{command} app-info`"),
89        ));
90    }
91    out
92}
93
94/// The problems of `part`'s fields, without reading a file.
95fn fields(part: &AppPart) -> Vec<AppProblem> {
96    let mut out = Vec::new();
97    if let Err(e) = check_name(&part.name) {
98        out.push(AppProblem::error("name", e));
99    }
100    match check_id(&part.id) {
101        Err(e) => out.push(AppProblem::error("id", e)),
102        Ok(()) if part.id.starts_with(PLACEHOLDER) => out.push(AppProblem::new(
103            Level::Warning,
104            "id",
105            format!(
106                "\"{}\" is a placeholder: two games with this id share their saves on one \
107                 computer and on one web origin; set your own before you hand out a build, and \
108                 before the first release, since the save folder follows it",
109                part.id
110            ),
111        )),
112        Ok(()) => {}
113    }
114    if let Some(version) = &part.version
115        && let Err(e) = check_version(version)
116    {
117        out.push(AppProblem::error("version", e));
118    }
119    if part.build == 0 {
120        out.push(AppProblem::error("build", "the build is 0: it starts at 1"));
121    }
122    let field = if part.storage.is_some() {
123        "storage"
124    } else {
125        "id"
126    };
127    if let Err(e) = check_storage(part.storage()) {
128        out.push(AppProblem::error(field, e));
129    }
130    if part.icon.is_some() && part.code.is_some() && part.window_icons.is_none() {
131        out.push(AppProblem::error(
132            "window_icons",
133            "the icon and `code` are set, so `window_icons` must be too: the folder where \
134             `app-info` writes the window's icons, which `code` reads",
135        ));
136    }
137    if !part.icon_sizes.is_empty() && (part.icon.is_none() || part.window_icons.is_none()) {
138        out.push(AppProblem::error(
139            "icon_sizes",
140            "pictures drawn per size need `icon`, which makes the sizes they don't draw, and \
141             `window_icons`, the folder `app-info` writes them to",
142        ));
143    }
144    for side in part.icon_sizes.keys() {
145        if !WINDOW_ICON_SIDES.contains(side) {
146            out.push(AppProblem::error(
147                "icon_sizes",
148                format!("{side} isn't a size of the window's icons: {WINDOW_ICON_SIDES:?}"),
149            ));
150        }
151    }
152    out
153}
154
155/// The problems of `part`'s fields, and of its icon, read from `root`.
156fn checked(part: &AppPart, root: &Path) -> Vec<AppProblem> {
157    let mut out = fields(part);
158    if let Err(e) = icon(part, root) {
159        out.push(AppProblem::error("icon", e));
160    }
161    let launcher = [
162        ("playdate_icon", &part.playdate_icon, (32, 32)),
163        ("playdate_card", &part.playdate_card, (350, 155)),
164    ];
165    for (field, path, size) in launcher {
166        if let Some(path) = path
167            && let Err(e) = sized_png(root, path, size)
168        {
169            out.push(AppProblem::error(field, e));
170        }
171    }
172    for (&side, path) in &part.icon_sizes {
173        if let Err(e) = sized_png(root, path, (side, side)) {
174            out.push(AppProblem::error("icon_sizes", e));
175        }
176    }
177    out
178}
179
180/// The RGBA8 pixels of `path` in the project at `root`, which must be a PNG
181/// of `size` pixels: the size a device's launcher asks, or a drawn icon's.
182fn sized_png(root: &Path, path: &str, size: (u32, u32)) -> Result<Vec<u8>, String> {
183    let bytes = fs::read(root.join(path)).map_err(|e| format!("{path} can't be read: {e}"))?;
184    let (width, height, rgba) =
185        codec::decode_png(&bytes).map_err(|e| format!("{path} isn't a PNG file: {e}"))?;
186    if (width, height) == size {
187        Ok(rgba)
188    } else {
189        Err(format!(
190            "{path} is {width} × {height} pixels: it must be {} × {}",
191            size.0, size.1
192        ))
193    }
194}
195
196/// The game's icon: `side` × `side` RGBA8 pixels.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Icon {
199    /// Its width and height, in pixels.
200    pub side: u32,
201    /// Its pixels, row by row, four bytes each.
202    pub rgba: Vec<u8>,
203}
204
205/// `part`'s icon, read from the project at `root` and checked: a PNG
206/// file, square, [`ICON_SIDE`] pixels a side or more. `None` when the part
207/// names none.
208pub fn icon(part: &AppPart, root: &Path) -> Result<Option<Icon>, String> {
209    let Some(path) = &part.icon else {
210        return Ok(None);
211    };
212    let bytes = fs::read(root.join(path)).map_err(|e| format!("{path} can't be read: {e}"))?;
213    let (width, height, rgba) =
214        codec::decode_png(&bytes).map_err(|e| format!("{path} isn't a PNG file: {e}"))?;
215    if width != height {
216        return Err(format!(
217            "{path} is {width} × {height} pixels: an icon is square"
218        ));
219    }
220    if width < ICON_SIDE {
221        return Err(format!(
222            "{path} is {width} pixels a side: an icon is {ICON_SIDE} or more, so every size a \
223             target asks is made from it by downscaling"
224        ));
225    }
226    Ok(Some(Icon { side: width, rgba }))
227}
228
229/// `rgba`, a `width` × `height` picture of RGBA8 pixels, downscaled to
230/// `size` × `size`, in integers, so the same picture gives the same bytes
231/// everywhere (ADR-0061). Each pixel out is the mean of the pixels of its
232/// square in, from `x * width / size` up to `(x + 1) * width / size` (and
233/// the same down), in premultiplied alpha, so a transparent pixel adds
234/// nothing to the colour beside it; each mean is rounded half up.
235pub fn downscale(width: u32, height: u32, rgba: &[u8], size: u32) -> Vec<u8> {
236    let bounds = |at: u32, whole: u32| {
237        let from = u64::from(at) * u64::from(whole) / u64::from(size);
238        let to = (u64::from(at) + 1) * u64::from(whole) / u64::from(size);
239        (from, to.max(from + 1))
240    };
241    let mut out = Vec::with_capacity(4 * size as usize * size as usize);
242    for y in 0..size {
243        let (top, bottom) = bounds(y, height);
244        for x in 0..size {
245            let (left, right) = bounds(x, width);
246            // The sum of alpha, and of each colour times its alpha.
247            let (mut alpha, mut colour) = (0_u64, [0_u64; 3]);
248            for row in top..bottom {
249                for column in left..right {
250                    let at = usize::try_from(4 * (row * u64::from(width) + column))
251                        .unwrap_or(usize::MAX);
252                    let Some(pixel) = rgba.get(at..at + 4) else {
253                        continue;
254                    };
255                    let a = u64::from(pixel[3]);
256                    alpha += a;
257                    for (sum, &c) in colour.iter_mut().zip(pixel) {
258                        *sum += u64::from(c) * a;
259                    }
260                }
261            }
262            let count = (bottom - top) * (right - left);
263            for sum in colour {
264                out.push(if alpha == 0 { 0 } else { half_up(sum, alpha) });
265            }
266            out.push(half_up(alpha, count));
267        }
268    }
269    out
270}
271
272/// `sum / count`, rounded half up, as a byte: the mean of bytes is one.
273fn half_up(sum: u64, count: u64) -> u8 {
274    u8::try_from((2 * sum + count) / (2 * count)).unwrap_or(u8::MAX)
275}
276
277/// Whether `name` can be the name players see: not empty, with no
278/// control character.
279fn check_name(name: &str) -> Result<(), String> {
280    if name.trim().is_empty() {
281        return Err("the name is empty".to_owned());
282    }
283    match name.chars().find(|c| c.is_control()) {
284        Some(c) => Err(format!("the name may not hold {c:?}")),
285        None => Ok(()),
286    }
287}
288
289/// Whether `id` is a reverse-DNS id: two or more parts between dots, each
290/// of ASCII letters, digits and `-`, starting with a letter. These are the
291/// characters Apple allows in a bundle id, with a letter first as Android
292/// asks.
293pub fn check_id(id: &str) -> Result<(), String> {
294    let parts: Vec<&str> = id.split('.').collect();
295    if parts.len() < 2 {
296        return Err(format!(
297            "\"{id}\" isn't reverse-DNS: it needs two or more parts between dots, as \
298             com.example.moon-garden"
299        ));
300    }
301    for part in parts {
302        if !part.starts_with(|c: char| c.is_ascii_alphabetic()) {
303            return Err(format!(
304                "\"{id}\": each part between dots starts with a letter, and \"{part}\" doesn't"
305            ));
306        }
307        if let Some(c) = part
308            .chars()
309            .find(|&c| !(c.is_ascii_alphanumeric() || c == '-'))
310        {
311            return Err(format!(
312                "\"{id}\" may hold only ASCII letters, digits, `-` and dots, not {c:?}"
313            ));
314        }
315    }
316    Ok(())
317}
318
319/// Whether `version` is semver: `MAJOR.MINOR.PATCH` in digits, then an
320/// optional `-pre-release` and `+build`.
321pub fn check_version(version: &str) -> Result<(), String> {
322    semver::Version::parse(version).map(|_| ()).map_err(|e| {
323        format!("\"{version}\" isn't semver (MAJOR.MINOR.PATCH, as 1.2.0 or 1.2.0-beta.1): {e}")
324    })
325}
326
327/// Whether `storage` can name a folder on every system: ASCII letters,
328/// digits, `.`, `-`, `_` and spaces, with no `.` or space first or last.
329pub fn check_storage(storage: &str) -> Result<(), String> {
330    if storage.is_empty() {
331        return Err("the storage's name is empty".to_owned());
332    }
333    if let Some(c) = storage
334        .chars()
335        .find(|&c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ' ')))
336    {
337        return Err(format!(
338            "the storage's name \"{storage}\" names a folder: ASCII letters, digits, `.`, `-`, \
339             `_` and spaces only, not {c:?}"
340        ));
341    }
342    let ends = [storage.chars().next(), storage.chars().last()];
343    if ends.iter().flatten().any(|&c| matches!(c, '.' | ' ')) {
344        return Err(format!(
345            "the storage's name \"{storage}\" names a folder: no `.` or space first or last"
346        ));
347    }
348    Ok(())
349}
350
351/// `text` as a Rust string literal: in quotes, with only `"`, `\` and
352/// control characters escaped.
353fn quoted(text: &str) -> String {
354    let mut out = String::from("\"");
355    for c in text.chars() {
356        match c {
357            '"' => out.push_str("\\\""),
358            '\\' => out.push_str("\\\\"),
359            // Writing to a `String` can't fail.
360            c if c.is_control() => {
361                let _ = write!(out, "\\u{{{:x}}}", u32::from(c));
362            }
363            c => out.push(c),
364        }
365    }
366    out.push('"');
367    out
368}
369
370/// The Rust file `app-info` writes for `part`, with `command` as the
371/// tool's name: one `pub const APP: AppInfo`, kept from `rustfmt`, so
372/// `app-info --check` is its only judge.
373pub fn rust(part: &AppPart, command: &str) -> String {
374    let version = part
375        .version
376        .as_deref()
377        .map_or_else(|| "env!(\"CARGO_PKG_VERSION\")".to_owned(), quoted);
378    let mut app = format!(
379        "AppInfo::new({}, {}, {version})",
380        quoted(&part.name),
381        quoted(&part.id)
382    );
383    if let Some(storage) = &part.storage {
384        let _ = write!(app, "\n    .with_storage({})", quoted(storage));
385    }
386    if part.build != 1 {
387        let _ = write!(app, "\n    .with_build({})", part.build);
388    }
389    if !part.publisher.is_empty() {
390        let _ = write!(app, "\n    .with_publisher({})", quoted(&part.publisher));
391    }
392    let mut uses = "AppInfo";
393    if let Some(paths) = window_icon_paths(part) {
394        uses = "{AppInfo, WindowIcon}";
395        let code = part.code.as_deref().unwrap_or_default();
396        app.push_str("\n    .with_window_icon(Some(WindowIcon::new(");
397        for (_, path) in paths {
398            let _ = write!(
399                app,
400                "\n        include_bytes!({}),",
401                quoted(&relative(code, &path))
402            );
403        }
404        app.push_str("\n    )))");
405    }
406    format!(
407        "// Generated by `{command} {COMMAND}` from {FILE}'s `{PART}`. Do not edit.\n\
408         //! Who the game is, as `{FILE}`'s `{PART}` says: its name, id and version, and the\n\
409         //! name its saves are kept under.\n\
410         \n\
411         use shuyi::platform::{uses};\n\
412         \n\
413         /// Who the game is: the window's title, the loading card and the storage read it.\n\
414         // check-text: not player text, who the game is (`{FILE}`'s `{PART}`)\n\
415         #[rustfmt::skip]\n\
416         pub const APP: AppInfo = {app};\n"
417    )
418}
419
420/// The command's name.
421pub const COMMAND: &str = "app-info";
422
423/// The window's icons `app-info` writes for `part`, each side and path from
424/// the project's root (`<window_icons>/16.png`, …): `None` unless the part
425/// has an icon and `window_icons`.
426fn window_icon_paths(part: &AppPart) -> Option<[(u32, String); 3]> {
427    part.icon.as_ref()?;
428    let folder = part.window_icons.as_deref()?.trim_end_matches(['/', '\\']);
429    Some(WINDOW_ICON_SIDES.map(|side| (side, format!("{folder}/{side}.png"))))
430}
431
432/// Whether `path`, a file `app-info` writes, is a picture: one of the
433/// window's icons, not the Rust file.
434fn is_png(path: &str) -> bool {
435    Path::new(path)
436        .extension()
437        .is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
438}
439
440/// `to`, a path from the project's root, as a path from the folder of
441/// `from`, another, with `/` between its parts: what `include_bytes!` in
442/// `from` names `to` by.
443fn relative(from: &str, to: &str) -> String {
444    let parts = |path: &str| -> Vec<String> {
445        path.split(['/', '\\'])
446            .filter(|part| !part.is_empty() && *part != ".")
447            .map(str::to_owned)
448            .collect()
449    };
450    let mut folder = parts(from);
451    folder.pop();
452    let to = parts(to);
453    let shared = folder.iter().zip(&to).take_while(|(a, b)| a == b).count();
454    let mut out = vec!["..".to_owned(); folder.len() - shared];
455    out.extend_from_slice(&to[shared..]);
456    out.join("/")
457}
458
459/// The Rust file `app-info` writes for `part`, its path and its bytes.
460fn rust_file(part: &AppPart, command: &str) -> Option<(String, Vec<u8>)> {
461    let path = part.code.clone()?;
462    Some((path, rust(part, command).into_bytes()))
463}
464
465/// The files `app-info` writes for `part` in the project at `root`: each
466/// path from the root and its bytes, the Rust file and then the window's
467/// icons, each the picture `icon_sizes` draws for its side, else downscaled
468/// from the icon. Fails when the icon or a drawn picture doesn't load.
469pub fn files(part: &AppPart, root: &Path, command: &str) -> Result<Vec<(String, Vec<u8>)>, String> {
470    let mut out: Vec<_> = rust_file(part, command).into_iter().collect();
471    if let Some(paths) = window_icon_paths(part)
472        && let Some(icon) = icon(part, root)?
473    {
474        for (side, path) in paths {
475            let pixels = match part.icon_sizes.get(&side) {
476                Some(drawn) => sized_png(root, drawn, (side, side))?,
477                None => downscale(icon.side, icon.side, &icon.rgba, side),
478            };
479            out.push((path, codec::encode_png(side, side, &pixels)?));
480        }
481    }
482    Ok(out)
483}
484
485/// The files `app-info` would write for `part` that differ from those in
486/// `root` (a missing file differs), by their paths. A text file's line
487/// endings don't count. When the icon or a drawn picture doesn't load, the
488/// window's icons aren't compared: its rule says why.
489pub fn stale(part: &AppPart, root: &Path, command: &str) -> Vec<String> {
490    let files = files(part, root, command)
491        .unwrap_or_else(|_| rust_file(part, command).into_iter().collect());
492    files
493        .into_iter()
494        .filter(|(path, bytes)| {
495            fs::read(root.join(path)).map_or(true, |disk| {
496                if is_png(path) {
497                    return disk != *bytes;
498                }
499                let lf: Vec<u8> = disk.into_iter().filter(|&b| b != b'\r').collect();
500                lf != *bytes
501            })
502        })
503        .map(|(path, _)| path)
504        .collect()
505}
506
507/// `app-info [--check]` on `part` in the project at `root`: writes its
508/// files, or with `check` writes nothing and fails naming each file that
509/// differs. A part with an error writes nothing and fails with them.
510pub fn run(part: &AppPart, root: &Path, command: &str, check: bool) -> Result<String, String> {
511    let errors: Vec<String> = checked(part, root)
512        .into_iter()
513        .filter(|p| p.level == Level::Error)
514        .map(|p| format!("{}: {}", p.setting, p.message))
515        .collect();
516    if !errors.is_empty() {
517        return Err(format!(
518            "{FILE}'s `{PART}` has {} error(s):\n  {}",
519            errors.len(),
520            errors.join("\n  ")
521        ));
522    }
523    let files = files(part, root, command)?;
524    if files.is_empty() {
525        return Ok(format!(
526            "{COMMAND}: {FILE}'s `{PART}` names no `code`: no file to write"
527        ));
528    }
529    if check {
530        let stale = stale(part, root, command);
531        if stale.is_empty() {
532            return Ok(format!("{COMMAND}: up to date"));
533        }
534        return Err(format!(
535            "{} file(s) out of date: {}: run `{command} {COMMAND}`",
536            stale.len(),
537            stale.join(", ")
538        ));
539    }
540    let mut wrote = Vec::new();
541    for (path, bytes) in files {
542        let full = root.join(&path);
543        if let Some(parent) = full.parent() {
544            fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
545        }
546        fs::write(&full, bytes).map_err(|e| format!("write {path}: {e}"))?;
547        wrote.push(path);
548    }
549    Ok(format!("{COMMAND}: wrote {}", wrote.join(", ")))
550}
551
552/// `app-info`'s arguments: none, or `--check`. `Ok(true)` for `--check`.
553pub fn parse_args(args: &[String]) -> Result<bool, String> {
554    match args {
555        [] => Ok(false),
556        [flag] if flag == "--check" => Ok(true),
557        _ => Err("no arguments, or --check".to_owned()),
558    }
559}
560
561#[cfg(test)]
562mod tests;