Skip to main content

shuyi_cli/contract/
export.rs

1//! `export <target> [--out <dir>] [--release] [--run] [--without <feature>]`:
2//! the game checked
3//! against the target (`check --target` with the target's profile: the
4//! `desktop` one for `windows`, `linux` and `macos`), refused on any
5//! error, then built and packaged by the target's exporter
6//! ([`crate::export`]). The package goes in `--out` (from the game's
7//! folder; `dist/export/<target>` without it) with `export-report.json`,
8//! which holds this answer's result: the target, the version, the sizes,
9//! the check's warnings, the signing and, with `--release`, the debug file
10//! kept beside the package. The final package is read back into
11//! `exposure-report.json`, and refused if it breaks the game's `disclosure`
12//! part ([`crate::export::disclosure`]). With `--run`, the package is run
13//! after that.
14
15use std::fmt::Write as _;
16use std::fs;
17use std::path::Path;
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22use super::check::{self, CheckInput};
23use super::target::{Level, TargetProblem};
24use super::{Answer, Op, codes};
25use crate::export::{self as exporters, ExportCx, ExportError, RunCx, disclosure};
26use crate::game::GameTools;
27use crate::project::Project;
28
29/// The report's file, beside the package.
30pub const REPORT: &str = "export-report.json";
31
32/// What `export` makes.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34#[serde(deny_unknown_fields)]
35// An input: built with `default()` and its fields assigned, it may grow
36// (ADR-0039).
37#[non_exhaustive]
38pub struct ExportInput {
39    /// The target: `windows`, `linux`, `macos`, `web` or `playdate`. Any other
40    /// target's check runs, and the answer says it has no exporter yet.
41    pub target: String,
42    /// The folder the package goes in, from the game's folder;
43    /// `dist/export/<target>` without it.
44    #[serde(default)]
45    pub out: Option<String>,
46    /// Whether to build with `--release`: the build's paths remapped and
47    /// searched for, and on Windows and Linux the debug file kept beside
48    /// the package.
49    #[serde(default)]
50    pub release: bool,
51    /// Whether to run the package after writing it.
52    #[serde(default)]
53    pub run: bool,
54    /// Features of `shuyi.ron`'s `export.features` to leave off this build
55    /// (a build without the files a feature needs, say).
56    #[serde(default)]
57    pub without: Vec<String>,
58}
59
60/// How a package is signed.
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62#[serde(rename_all = "kebab-case")]
63pub enum Signing {
64    /// Not signed.
65    #[default]
66    None,
67    /// Signed by no one (`codesign --sign -`): Apple silicon runs it, and
68    /// the player opens it once with Open Anyway.
69    AdHoc,
70}
71
72/// A file of the export and its size.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct FileSize {
76    /// The file, from the export's folder (`game-0.1.0-windows.zip`,
77    /// `game-0.1.0-windows/game.exe`).
78    pub file: String,
79    /// Its size in bytes.
80    pub bytes: u64,
81}
82
83/// What `export` made: `export-report.json`.
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85#[serde(deny_unknown_fields)]
86pub struct ExportOutput {
87    /// The target.
88    pub target: String,
89    /// The target profile its check ran with.
90    pub profile: String,
91    /// The game's version: its package's.
92    pub version: String,
93    /// The export's folder, as the game shows it.
94    pub out: String,
95    /// The package, from the export's folder: a zip, a `tar.gz` or a
96    /// folder.
97    pub package: String,
98    /// The folder the package was made from, from the export's folder:
99    /// what `--run` runs.
100    pub folder: String,
101    /// The package's size first (for `web`, its zip's), then each program
102    /// in it.
103    pub sizes: Vec<FileSize>,
104    /// The check's warnings.
105    pub warnings: Vec<TargetProblem>,
106    /// How the package is signed.
107    pub signing: Signing,
108    /// With `--release` on Windows and Linux, the program with its debug
109    /// sections, kept beside the package and never in it
110    /// (`game-0.1.0-linux.debug`).
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub debug: Option<String>,
113    /// What `shuyi.ron`'s `export.beside` copied into the package, from the
114    /// export's folder, a folder with the bytes of all its files.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub beside: Vec<FileSize>,
117}
118
119/// `export` on `project`: the target's check, with the game's tools
120/// `game` if they are at hand, then its exporter.
121pub fn export(
122    project: &Project,
123    input: &ExportInput,
124    game: Option<&dyn GameTools>,
125) -> Answer<ExportOutput> {
126    let op = Op::Export;
127    let exporter = exporters::find(&input.target);
128    let profile = exporter.map_or(input.target.as_str(), |exporter| exporter.profile());
129    let checked = check::targets(
130        &project.config,
131        &project.root,
132        &CheckInput {
133            target: Some(profile.to_owned()),
134            ..CheckInput::default()
135        },
136        game,
137    );
138    let Some(found) = &checked.result else {
139        let code = checked.code.as_deref().and_then(codes::entry);
140        let error = checked.error.clone().unwrap_or_default();
141        return Answer::failed(op, code.map_or(codes::TARGET, |entry| entry.code), error);
142    };
143    let problems: Vec<&TargetProblem> = found.targets.iter().flat_map(|t| &t.problems).collect();
144    if !checked.ok {
145        let mut error = format!("check --target {profile} found errors; nothing was built:");
146        for problem in problems.iter().filter(|p| p.level == Level::Error) {
147            let _ = write!(error, "\n  {}: {}", problem.setting, problem.message);
148        }
149        return Answer::failed(op, codes::TARGET_CHECK, error);
150    }
151    let Some(exporter) = exporter else {
152        let error = format!(
153            "{} passes check --target {profile}, but there is no exporter for it yet (the \
154             exporters: {})",
155            input.target,
156            exporters::ids().join(", ")
157        );
158        return Answer::failed(op, codes::NO_EXPORTER, error);
159    };
160    let part = project.config.disclosure.as_ref();
161    if let Some(part) = part {
162        let problems = disclosure::problems(part);
163        if !problems.is_empty() {
164            let error = format!(
165                "{} can't be used; nothing was built:\n  {}",
166                crate::project::FILE,
167                problems.join("\n  ")
168            );
169            return Answer::failed(op, codes::PROJECT_FILE, error);
170        }
171    }
172    let cx = match ExportCx::of(project, exporter, input) {
173        Ok(cx) => cx,
174        Err(e) => return failed(&e),
175    };
176    if let Err(e) = exporter.prepare(&cx, project, game) {
177        return failed(&e);
178    }
179    let mut report = match exporter.export(&cx) {
180        Ok(report) => report,
181        Err(e) => return failed(&e),
182    };
183    let shipped = exporter.shipped(&cx, &report);
184    if let Err((code, error)) = disclosure::gate(&cx, exporter, part, &shipped) {
185        return Answer::failed(op, code, error);
186    }
187    report.warnings.extend(
188        problems
189            .into_iter()
190            .filter(|p| p.level == Level::Warning)
191            .cloned(),
192    );
193    let mut answer = Answer::new(op, true, report, checked.limits);
194    let json = serde_json::to_string_pretty(&answer.result).unwrap_or_default();
195    if let Err(e) = fs::write(cx.out.join(REPORT), format!("{json}\n")) {
196        let error = format!("write {}: {e}", cx.out.join(REPORT).display());
197        return Answer::failed(op, codes::FILE, error);
198    }
199    if input.run
200        && let Some(report) = &answer.result
201        && let Err(e) = exporter.run(&RunCx {
202            out: &cx.out,
203            report,
204        })
205    {
206        answer.ok = false;
207        answer.error = Some(e.message());
208        answer = answer.with_code(e.code());
209    }
210    answer
211}
212
213/// An answer of `export` that failed with `error`.
214fn failed(error: &ExportError) -> Answer<ExportOutput> {
215    Answer::failed(Op::Export, error.code(), error.message())
216}
217
218/// `export`'s arguments (`--json` taken out): the target, then
219/// `--out <dir>`, `--release` and `--run` in any order, each once, and
220/// `--without <feature>`, any number of times.
221pub fn parse_args(args: &[String]) -> Result<ExportInput, String> {
222    let mut input = ExportInput::default();
223    let mut rest = args.iter();
224    let mut target = None;
225    while let Some(arg) = rest.next() {
226        let once = |seen: bool| {
227            if seen {
228                Err(format!("{arg} twice"))
229            } else {
230                Ok(true)
231            }
232        };
233        match arg.as_str() {
234            "--release" => input.release = once(input.release)?,
235            "--run" => input.run = once(input.run)?,
236            "--out" => {
237                once(input.out.is_some())?;
238                let dir = rest.next().ok_or("--out needs a folder")?;
239                input.out = Some(dir.clone());
240            }
241            "--without" => {
242                let feature = rest.next().ok_or("--without needs a feature")?;
243                input.without.push(feature.clone());
244            }
245            flag if flag.starts_with("--") => return Err(format!("unknown option {flag}")),
246            _ if target.is_none() => target = Some(arg.clone()),
247            _ => return Err("one target".to_owned()),
248        }
249    }
250    input.target = target.ok_or("a target: windows, linux, macos, web or playdate")?;
251    Ok(input)
252}
253
254/// `export`'s usage, with `command` as the tool's name.
255pub fn usage(command: &str) -> String {
256    format!(
257        "usage: {command} export <windows | linux | macos | web | playdate> [--out <dir>] [--release] \
258         [--run] [--without <feature>] [--json]"
259    )
260}
261
262/// What `export` made, for people.
263pub fn text(output: &ExportOutput) -> String {
264    let out = Path::new(&output.out);
265    let mut text = format!(
266        "export {}: {} ({}, check --target {})\n",
267        output.target,
268        out.join(&output.package).display(),
269        output.version,
270        output.profile
271    );
272    for size in &output.sizes {
273        let _ = writeln!(text, "  {}: {} bytes", size.file, size.bytes);
274    }
275    for size in &output.beside {
276        let _ = writeln!(text, "  beside: {}: {} bytes", size.file, size.bytes);
277    }
278    if let Some(debug) = &output.debug {
279        let _ = writeln!(text, "  debug file: {}", out.join(debug).display());
280    }
281    let signing = match output.signing {
282        Signing::None => "none",
283        Signing::AdHoc => "ad-hoc",
284    };
285    let _ = writeln!(text, "  signing: {signing}");
286    let _ = writeln!(text, "  report: {}", out.join(REPORT).display());
287    let exposure = out.join(disclosure::REPORT);
288    let _ = writeln!(text, "  exposure: {}", exposure.display());
289    for warning in &output.warnings {
290        let _ = writeln!(text, "  warning: {}: {}", warning.setting, warning.message);
291    }
292    text
293}
294
295#[cfg(test)]
296mod tests;