1use 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
29pub const REPORT: &str = "export-report.json";
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34#[serde(deny_unknown_fields)]
35#[non_exhaustive]
38pub struct ExportInput {
39 pub target: String,
42 #[serde(default)]
45 pub out: Option<String>,
46 #[serde(default)]
50 pub release: bool,
51 #[serde(default)]
53 pub run: bool,
54 #[serde(default)]
57 pub without: Vec<String>,
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
62#[serde(rename_all = "kebab-case")]
63pub enum Signing {
64 #[default]
66 None,
67 AdHoc,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct FileSize {
76 pub file: String,
79 pub bytes: u64,
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
85#[serde(deny_unknown_fields)]
86pub struct ExportOutput {
87 pub target: String,
89 pub profile: String,
91 pub version: String,
93 pub out: String,
95 pub package: String,
98 pub folder: String,
101 pub sizes: Vec<FileSize>,
104 pub warnings: Vec<TargetProblem>,
106 pub signing: Signing,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub debug: Option<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub beside: Vec<FileSize>,
117}
118
119pub 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
213fn failed(error: &ExportError) -> Answer<ExportOutput> {
215 Answer::failed(Op::Export, error.code(), error.message())
216}
217
218pub 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
254pub 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
262pub 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;