Skip to main content

shuyi_cli/contract/
import.rs

1//! `import <file> [--kind K] [--print]`: a file read through the format
2//! registry (ADR-0019) as every kind whose formats know it, with a short
3//! summary of what was read, the asset itself with `--print`, or every
4//! error with its file and line.
5//!
6//! The kinds tried are [`Importers`]: the engine's standard ones
7//! ([`Importers::standard`]) and those the game adds with its own formats
8//! ([`GameTools::importers`](crate::GameTools::importers)). The importers
9//! of other tools' files (Tiled, `LDtk`, Aseprite) are formats of the
10//! registry too, when tickets 1123 and 1124 add them; `import` needs no
11//! change for them.
12
13use std::collections::BTreeMap;
14use std::fmt::{Debug, Write as _};
15use std::fs;
16use std::path::Path;
17
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use shuyi_assets::ContentError;
21use shuyi_assets::audio::{AudioManifest, MusicTrack, Sound};
22use shuyi_assets::bundle::{Bundle, Layer};
23use shuyi_assets::credits::CreditsFile;
24use shuyi_assets::dialogue::{DialogueFile, NoDirective};
25use shuyi_assets::font::FontAtlasDef;
26use shuyi_assets::format::{AssetKind, AssetPath, DecodeCx, Formats};
27use shuyi_assets::image::ImageInfo;
28use shuyi_assets::lighting::Lighting;
29use shuyi_assets::names::Names;
30use shuyi_assets::palette::PaletteDef;
31
32use super::{Answer, Op, Problem, codes};
33
34/// The most files of the file's folder read for the files it names.
35pub const MAX_FOLDER_FILES: usize = 1_000;
36
37/// The most bytes of the file's folder read for the files it names.
38pub const MAX_FOLDER_BYTES: usize = 64 << 20;
39
40/// What `import` reads.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(deny_unknown_fields)]
43// An input: built with `ImportInput::new` (or `default()`) and its fields
44// assigned, it may grow (ADR-0039).
45#[non_exhaustive]
46pub struct ImportInput {
47    /// The file, from the project's root (or the working folder outside a
48    /// project).
49    pub file: String,
50    /// Only this kind (`palette`, `image`, `map`…); every kind if none.
51    #[serde(default)]
52    pub kind: Option<String>,
53    /// Whether to give the asset itself, not only its summary.
54    #[serde(default)]
55    pub print: bool,
56}
57
58impl ImportInput {
59    /// The file `file`, read as every kind, summarised (not printed).
60    pub fn new(file: impl Into<String>) -> Self {
61        Self {
62            file: file.into(),
63            kind: None,
64            print: false,
65        }
66    }
67}
68
69/// The file read as one kind.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
71#[serde(deny_unknown_fields)]
72pub struct Imported {
73    /// The kind (`palette`).
74    pub kind: String,
75    /// What was read, in a line (`palette: 21 colors`).
76    pub summary: String,
77    /// With `print`, the asset: in our text format if the kind has a
78    /// writer, else as read, in Rust's debug form.
79    pub text: Option<String>,
80}
81
82/// What `import` found.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[serde(deny_unknown_fields)]
85pub struct ImportOutput {
86    /// The file.
87    pub file: String,
88    /// Every kind it reads as.
89    pub read_as: Vec<Imported>,
90    /// When it reads as none: the errors of the kinds that claim it.
91    pub errors: Vec<Problem>,
92}
93
94/// What reading a file as one kind gave: its summary, the asset as text
95/// if asked for, and whether that text is our own format.
96type Read = (String, Option<String>, bool);
97
98/// A kind `import` tries, over the registry's formats of it.
99trait ImportKind: Send + Sync {
100    fn name(&self) -> &'static str;
101
102    /// Whether a format of the kind claims `path`'s extension.
103    fn claims(&self, formats: &Formats, path: &str) -> bool;
104
105    fn read(
106        &self,
107        formats: &Formats,
108        file: &AssetPath,
109        bytes: &[u8],
110        cx: &DecodeCx<'_>,
111        print: bool,
112    ) -> Result<Read, Vec<ContentError>>;
113}
114
115/// Kind `K`, its summary, and its writer in our text format if it has
116/// one.
117struct KindOf<K> {
118    summary: fn(&K) -> String,
119    text: Option<fn(&K) -> String>,
120}
121
122impl<K: AssetKind + Debug> ImportKind for KindOf<K> {
123    fn name(&self) -> &'static str {
124        K::NAME
125    }
126
127    fn claims(&self, formats: &Formats, path: &str) -> bool {
128        formats.claims::<K>(path)
129    }
130
131    fn read(
132        &self,
133        formats: &Formats,
134        file: &AssetPath,
135        bytes: &[u8],
136        cx: &DecodeCx<'_>,
137        print: bool,
138    ) -> Result<Read, Vec<ContentError>> {
139        let asset = formats.decode::<K>(file, bytes, cx)?;
140        let (text, ours) = match (print, self.text) {
141            (false, _) => (None, true),
142            (true, Some(write)) => (Some(write(&asset)), true),
143            (true, None) => (Some(format!("{asset:#?}")), false),
144        };
145        Ok(((self.summary)(&asset), text, ours))
146    }
147}
148
149/// The kinds `import` tries and the formats it reads them with.
150pub struct Importers {
151    /// The registry: the engine's standard formats, and the game's.
152    pub formats: Formats,
153    kinds: Vec<Box<dyn ImportKind>>,
154}
155
156impl Debug for Importers {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct("Importers")
159            .field("formats", &self.formats)
160            .field("kinds", &self.kinds())
161            .finish()
162    }
163}
164
165/// `n` things, as `1 color` or `3 colors`.
166fn count(n: usize, one: &str, many: &str) -> String {
167    if n == 1 {
168        format!("1 {one}")
169    } else {
170        format!("{n} {many}")
171    }
172}
173
174impl Importers {
175    /// The kinds of no format yet, over `formats`.
176    pub fn new(formats: Formats) -> Self {
177        Self {
178            formats,
179            kinds: Vec::new(),
180        }
181    }
182
183    /// The engine's standard kinds over its standard formats
184    /// ([`Formats::standard`]).
185    pub fn standard() -> Self {
186        Self::new(Formats::standard())
187            .kind::<ImageInfo>(|i| format!("{}×{} pixels", i.width, i.height))
188            .kind::<Sound>(|s| format!("{} channel(s) at {} Hz", s.channels, s.sample_rate))
189            .kind::<MusicTrack>(|m| {
190                format!(
191                    "{} channel(s) at {} Hz, {} ms long",
192                    m.channels, m.sample_rate, m.length_ms
193                )
194            })
195            .kind::<AudioManifest>(|a| {
196                format!(
197                    "{}, {}",
198                    count(a.sounds.len(), "sound", "sounds"),
199                    count(a.music.len(), "music track", "music tracks")
200                )
201            })
202            .kind::<CreditsFile>(|c| count(c.credits.len(), "credit", "credits"))
203            .kind::<FontAtlasDef>(|f| {
204                format!(
205                    "{} of {}×{} pixels",
206                    count(f.glyphs.len(), "glyph", "glyphs"),
207                    f.cell_w,
208                    f.cell_h
209                )
210            })
211            .kind::<PaletteDef>(|p| count(p.colors.len(), "color", "colors"))
212            .kind::<Lighting>(|l| count(l.lights.len(), "light", "lights"))
213            .kind::<Names>(|n| count(n.names.len(), "name", "names"))
214            .kind::<DialogueFile<NoDirective>>(|d| {
215                let ids: Vec<&str> = d.scenes.iter().map(|s| s.scene.id.as_str()).collect();
216                format!(
217                    "{}: {}",
218                    count(ids.len(), "scene", "scenes"),
219                    ids.join(", ")
220                )
221            })
222    }
223
224    /// These importers, trying kind `K` too, summed up by `summary`. Its
225    /// formats are the registry's ([`formats`](Self::formats)).
226    #[must_use]
227    pub fn kind<K: AssetKind + Debug>(mut self, summary: fn(&K) -> String) -> Self {
228        self.kinds.push(Box::new(KindOf::<K> {
229            summary,
230            text: None,
231        }));
232        self
233    }
234
235    /// [`kind`](Self::kind), with `text` writing the asset in our text
236    /// format for `--print`.
237    #[must_use]
238    pub fn kind_with_text<K: AssetKind + Debug>(
239        mut self,
240        summary: fn(&K) -> String,
241        text: fn(&K) -> String,
242    ) -> Self {
243        self.kinds.push(Box::new(KindOf::<K> {
244            summary,
245            text: Some(text),
246        }));
247        self
248    }
249
250    /// The kinds' names, in the order they are tried.
251    pub fn kinds(&self) -> Vec<&'static str> {
252        self.kinds.iter().map(|k| k.name()).collect()
253    }
254}
255
256/// A bundle of the files in `dir` and below it, for the files a file
257/// names (a font atlas's picture, a manifest's sounds), or why there is
258/// none: more than `max_files` files or `max_bytes` bytes. Its files are
259/// one run-time layer ([`Layer::owned`]), kept for the program's life: a
260/// tool reads them once per run.
261fn folder_bundle(dir: &Path, max_files: usize, max_bytes: usize) -> Result<Bundle, String> {
262    let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
263    let mut pending = vec![(dir.to_path_buf(), String::new())];
264    let mut bytes = 0;
265    while let Some((path, prefix)) = pending.pop() {
266        let entries = fs::read_dir(&path).map_err(|e| format!("{}: {e}", path.display()))?;
267        for entry in entries.flatten() {
268            let name = entry.file_name().to_string_lossy().into_owned();
269            let inner = if prefix.is_empty() {
270                name
271            } else {
272                format!("{prefix}/{name}")
273            };
274            let kind = entry.file_type().map_err(|e| e.to_string())?;
275            if kind.is_dir() {
276                pending.push((entry.path(), inner));
277            } else if kind.is_file() {
278                let data = fs::read(entry.path()).map_err(|e| format!("{inner}: {e}"))?;
279                bytes += data.len();
280                files.insert(inner, data);
281            }
282            if files.len() > max_files || bytes > max_bytes {
283                return Err(format!(
284                    "its folder holds more than {max_files} files or {max_bytes} bytes"
285                ));
286            }
287        }
288    }
289    let layer = Layer::owned(String::new(), files).map_err(|e| e.to_string())?;
290    Ok(Bundle::new(vec![layer]))
291}
292
293/// `import`: `input`'s file, under `root`, read by `importers`.
294pub fn import(importers: &Importers, root: &Path, input: &ImportInput) -> Answer<ImportOutput> {
295    let path = root.join(&input.file);
296    let bytes = match fs::read(&path) {
297        Ok(bytes) => bytes,
298        Err(e) => return Answer::failed(Op::Import, codes::FILE, format!("{}: {e}", input.file)),
299    };
300    let kinds: Vec<&dyn ImportKind> = importers
301        .kinds
302        .iter()
303        .map(|k| &**k)
304        .filter(|k| input.kind.as_deref().is_none_or(|kind| kind == k.name()))
305        .collect();
306    if kinds.is_empty() {
307        let known = importers.kinds().join(", ");
308        return Answer::failed(
309            Op::Import,
310            codes::WRONG_ARGUMENTS,
311            format!(
312                "no kind `{}`: the kinds are {known}",
313                input.kind.as_deref().unwrap_or_default()
314            ),
315        );
316    }
317    let mut limits = Vec::new();
318    let name = path
319        .file_name()
320        .map(|n| n.to_string_lossy().into_owned())
321        .unwrap_or_default();
322    let folder = path
323        .parent()
324        .map(|dir| folder_bundle(dir, MAX_FOLDER_FILES, MAX_FOLDER_BYTES));
325    let bundle = match folder {
326        Some(Ok(bundle)) => Some(bundle),
327        Some(Err(why)) => {
328            limits.push(format!("the files it names were not read: {why}"));
329            None
330        }
331        None => None,
332    };
333    let cx = bundle
334        .as_ref()
335        .map_or_else(DecodeCx::new, DecodeCx::in_bundle);
336    let file = AssetPath::new(name.clone()).shown_as(input.file.clone());
337    let mut read_as = Vec::new();
338    let mut errors = Vec::new();
339    let mut claimed = false;
340    for kind in kinds {
341        let claims = kind.claims(&importers.formats, &name);
342        claimed |= claims;
343        match kind.read(&importers.formats, &file, &bytes, &cx, input.print) {
344            Ok((summary, text, ours)) => {
345                if !ours {
346                    limits.push(format!(
347                        "no writer of our text format for the {} kind yet: it is printed as read, \
348                         in Rust's debug form",
349                        kind.name()
350                    ));
351                }
352                read_as.push(Imported {
353                    kind: kind.name().to_owned(),
354                    summary: format!("{}: {summary}", kind.name()),
355                    text,
356                });
357            }
358            Err(e) if claims => errors.extend(e.iter().map(Problem::from)),
359            Err(_) => {}
360        }
361    }
362    if !read_as.is_empty() {
363        errors.clear();
364        if read_as.len() > 1 {
365            limits.push(format!(
366                "it reads as {} kinds: name the one meant with --kind",
367                read_as.len()
368            ));
369        }
370    } else if !claimed {
371        let message = format!(
372            "no format of these kinds knows its bytes or claims its extension: {}",
373            importers.kinds().join(", ")
374        );
375        let error = ContentError::new(input.file.clone(), message)
376            .with_code(shuyi_assets::error::codes::NO_FORMAT);
377        errors.push(Problem::from(&error));
378        limits.push(
379            "the importers of Tiled, LDtk and Aseprite files come with tickets 1123 and 1124"
380                .to_owned(),
381        );
382    }
383    let ok = !read_as.is_empty();
384    let output = ImportOutput {
385        file: input.file.clone(),
386        read_as,
387        errors,
388    };
389    Answer::new(Op::Import, ok, output, limits)
390}
391
392/// Reads `import`'s arguments (not `--json`).
393pub fn parse_args(args: &[String]) -> Result<ImportInput, String> {
394    let mut input = ImportInput::default();
395    let mut file = None;
396    let mut iter = args.iter();
397    while let Some(arg) = iter.next() {
398        match arg.as_str() {
399            "--print" => input.print = true,
400            "--kind" => {
401                input.kind = Some(iter.next().ok_or("--kind needs a value")?.clone());
402            }
403            flag if flag.starts_with("--") => return Err(format!("unknown option: {flag}")),
404            path if file.is_none() => file = Some(path.to_owned()),
405            extra => return Err(format!("one <file> only, not also {extra}")),
406        }
407    }
408    input.file = file.ok_or("missing <file>")?;
409    Ok(input)
410}
411
412/// What `import` found, for people.
413pub fn text(output: &ImportOutput) -> String {
414    let mut out = String::new();
415    for read in &output.read_as {
416        // Writing to a `String` can't fail.
417        let _ = writeln!(out, "import: {} is {}", output.file, read.summary);
418        if let Some(text) = &read.text {
419            out.push_str(text);
420            if !text.ends_with('\n') {
421                out.push('\n');
422            }
423        }
424    }
425    if !output.errors.is_empty() {
426        let _ = writeln!(out, "import: {} error(s)", output.errors.len());
427        for error in &output.errors {
428            let _ = writeln!(out, "  {error}");
429        }
430        out.push_str(&codes::help_lines(&output.errors));
431    }
432    out
433}
434
435#[cfg(test)]
436mod tests;