1pub mod archive;
26mod desktop;
27pub mod disclosure;
28pub mod paths;
29pub mod playdate;
30mod web;
31
32use std::collections::BTreeMap;
33use std::fs;
34use std::path::{Path, PathBuf};
35use std::process::Command;
36
37use serde::Deserialize;
38
39use self::disclosure::Shipped;
40use crate::contract::codes::{self, Code};
41use crate::contract::export::ExportInput;
42pub use crate::contract::export::{ExportOutput as ExportReport, FileSize, Signing};
43use crate::game::GameTools;
44use crate::notices::{self, Build, Work};
45use crate::project::{AppPart, Beside, Config, ExportPart, GameProgram, Project, Web};
46
47pub trait Exporter: Sync {
49 fn id(&self) -> &'static str;
51
52 fn profile(&self) -> &'static str;
54
55 fn triples(&self) -> &'static [&'static str];
57
58 fn builds_a_program(&self) -> bool {
61 true
62 }
63
64 fn cargo_args(&self, cx: &ExportCx) -> Vec<Vec<String>>;
66
67 fn takes_export_part(&self) -> bool {
69 true
70 }
71
72 fn prepare(
75 &self,
76 _cx: &ExportCx,
77 _project: &Project,
78 _game: Option<&dyn GameTools>,
79 ) -> Result<(), ExportError> {
80 Ok(())
81 }
82
83 fn export(&self, cx: &ExportCx) -> Result<ExportReport, ExportError>;
85
86 fn shipped(&self, cx: &ExportCx, report: &ExportReport) -> Shipped {
91 let archive = (report.package != report.folder).then(|| cx.out.join(&report.package));
92 Shipped::new(
93 cx.out.join(&report.folder),
94 archive,
95 format!("{}/", report.folder),
96 )
97 }
98
99 fn run(&self, _cx: &RunCx) -> Result<(), ExportError> {
101 Err(ExportError::NoRunStep(format!(
102 "no run step for {} yet",
103 self.id()
104 )))
105 }
106}
107
108static EXPORTERS: [&dyn Exporter; 5] = [
110 &desktop::WINDOWS,
111 &desktop::LINUX,
112 &desktop::MACOS,
113 &web::WEB,
114 &playdate::PLAYDATE,
115];
116
117pub fn exporters() -> &'static [&'static dyn Exporter] {
119 &EXPORTERS
120}
121
122pub fn find(id: &str) -> Option<&'static dyn Exporter> {
124 EXPORTERS
125 .iter()
126 .copied()
127 .find(|exporter| exporter.id() == id)
128}
129
130pub fn ids() -> Vec<&'static str> {
132 EXPORTERS.iter().map(|exporter| exporter.id()).collect()
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum ExportError {
138 MissingTool(String),
140 Setup(String),
142 Build(String),
144 BuildPath(String),
146 NoRunStep(String),
148}
149
150impl ExportError {
151 pub fn code(&self) -> Code {
153 match self {
154 Self::MissingTool(_) => codes::TOOLCHAIN,
155 Self::Setup(_) => codes::PROJECT_FILE,
156 Self::Build(_) => codes::PACKAGE,
157 Self::BuildPath(_) => codes::BUILD_PATH,
158 Self::NoRunStep(_) => codes::NO_EXPORTER,
159 }
160 }
161
162 pub fn message(&self) -> String {
164 match self {
165 Self::MissingTool(m)
166 | Self::Setup(m)
167 | Self::Build(m)
168 | Self::BuildPath(m)
169 | Self::NoRunStep(m) => m.clone(),
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct ExportCx {
177 pub target: String,
179 pub profile: String,
181 pub root: PathBuf,
183 pub workspace: PathBuf,
185 pub target_dir: PathBuf,
187 pub out: PathBuf,
189 pub shown: String,
191 pub release: bool,
193 pub package: String,
195 pub bin: String,
197 pub version: String,
200 pub title: String,
202 pub game: Vec<Work>,
204 pub web: Option<Web>,
206 pub app: Option<AppPart>,
208 pub features: Vec<String>,
210 pub turned_on: Vec<String>,
212 pub beside: Vec<Beside>,
214}
215
216pub struct RunCx<'a> {
218 pub out: &'a Path,
220 pub report: &'a ExportReport,
222}
223
224type Features = BTreeMap<String, Vec<String>>;
226
227#[derive(Deserialize)]
229struct Metadata {
230 packages: Vec<MetaPackage>,
231}
232
233#[derive(Deserialize)]
234struct MetaPackage {
235 name: String,
236 version: String,
237 targets: Vec<MetaTarget>,
238 features: BTreeMap<String, Vec<String>>,
239}
240
241#[derive(Deserialize)]
242struct MetaTarget {
243 name: String,
244 kind: Vec<String>,
245}
246
247impl ExportCx {
248 pub fn of(
252 project: &Project,
253 exporter: &dyn Exporter,
254 input: &ExportInput,
255 ) -> Result<Self, ExportError> {
256 let root = &project.root;
257 let config = &project.config;
258 let web = if exporter.id() == "web" {
259 Some(
260 project
261 .part(&config.web, "web")
262 .map_err(ExportError::Setup)?,
263 )
264 } else {
265 config.web.as_ref()
266 };
267 let part = config
268 .export
269 .as_ref()
270 .filter(|_| exporter.takes_export_part());
271 let named = part.and_then(|part| part.package.as_deref());
272 let package =
273 package_of(exporter.id(), named, config.game.as_ref(), web).ok_or_else(|| {
274 ExportError::Setup(format!(
275 "{} names no package to build: its `game` part names the game's",
276 crate::project::FILE
277 ))
278 })?;
279 let (package_version, bins, features) = metadata(root, &package)?;
280 let features = features.into_keys().collect();
281 let version = version(config.app.as_ref(), package_version);
282 let bin = match web {
283 Some(web) if exporter.id() == "web" => web.bin.clone(),
284 _ if !exporter.builds_a_program() => package.clone(),
285 _ => program(&package, &bins)?,
286 };
287 let shown = input
288 .out
289 .clone()
290 .unwrap_or_else(|| format!("dist/export/{}", exporter.id()));
291 let out = root.join(&shown);
292 fs::create_dir_all(&out)
293 .map_err(|e| ExportError::Build(format!("create {}: {e}", out.display())))?;
294 let game = match &config.notices {
295 Some(texts) => notices::game_texts(root, texts).map_err(ExportError::Setup)?,
296 None => Vec::new(),
297 };
298 let turned_on = turned_on(part, &input.without)?;
299 let mut beside = present(root, part)?;
300 if let Some(web) = web.filter(|_| exporter.id() == "web") {
301 beside.retain(|item| !in_web_build(item, web));
302 }
303 Ok(Self {
304 target: exporter.id().to_owned(),
305 profile: exporter.profile().to_owned(),
306 workspace: crate::web::workspace_root(root),
307 target_dir: crate::web::target_dir(root),
308 root: root.clone(),
309 out,
310 shown,
311 release: input.release,
312 package,
313 title: web.map_or_else(|| bin.clone(), |web| web.title.clone()),
314 bin,
315 version,
316 game,
317 web: web.cloned(),
318 app: config.app.clone(),
319 features,
320 turned_on,
321 beside,
322 })
323 }
324
325 pub fn base(&self) -> String {
328 format!("{}-{}-{}", self.bin, self.version, self.target)
329 }
330
331 pub fn cargo_args(&self, triple: &str) -> Vec<String> {
333 let mut args: Vec<String> = ["build", "-p", &self.package, "--bin", &self.bin]
334 .map(str::to_owned)
335 .to_vec();
336 args.extend(["--target".to_owned(), triple.to_owned()]);
337 if self.release {
338 args.push("--release".to_owned());
339 }
340 if !self.turned_on.is_empty() {
341 args.extend(["--features".to_owned(), self.turned_on.join(",")]);
342 }
343 args
344 }
345
346 pub fn artifact(&self, triple: &str) -> PathBuf {
348 let profile = if self.release { "release" } else { "debug" };
349 let exe = if triple.contains("-windows-") {
350 format!("{}.exe", self.bin)
351 } else {
352 self.bin.clone()
353 };
354 self.target_dir.join(triple).join(profile).join(exe)
355 }
356
357 pub fn needles(&self) -> Vec<PathBuf> {
359 paths::needles(&[&self.root, &self.workspace])
360 }
361
362 pub fn search(&self, file: &Path) -> Result<(), ExportError> {
365 if !self.release {
366 return Ok(());
367 }
368 paths::check_file(file, &self.needles()).map_err(ExportError::BuildPath)
369 }
370
371 pub fn build(&self, triple: &str, keep_debug: bool) -> Result<PathBuf, ExportError> {
375 installed(triple)?;
376 let args = self.cargo_args(triple);
377 no_live_link(&args)?;
378 let mut cargo = Command::new(crate::cargo());
379 cargo.args(&args).current_dir(&self.root);
380 if self.release {
381 let outside = paths::outside(&self.root, &self.workspace);
382 let remaps = paths::remaps(&self.workspace, &self.target_dir, &self.package, &outside);
383 cargo.env(paths::ENCODED, paths::remapped_flags_here(&remaps));
384 if keep_debug {
385 cargo.env("CARGO_PROFILE_RELEASE_STRIP", "false");
386 cargo.env("CARGO_PROFILE_RELEASE_DEBUG", "line-tables-only");
387 }
388 }
389 let status = cargo
390 .status()
391 .map_err(|e| ExportError::MissingTool(format!("cargo doesn't start: {e}")))?;
392 if !status.success() {
393 return Err(ExportError::Build(format!(
394 "cargo {} exited with {status}",
395 args.join(" ")
396 )));
397 }
398 Ok(self.artifact(triple))
399 }
400
401 pub fn size(&self, file: &Path) -> Result<FileSize, ExportError> {
403 let bytes = if file.is_dir() {
404 folder_size(file)?
405 } else {
406 fs::metadata(file)
407 .map_err(|e| ExportError::Build(format!("stat {}: {e}", file.display())))?
408 .len()
409 };
410 let name = file.strip_prefix(&self.out).unwrap_or(file);
411 Ok(FileSize {
412 file: name.to_string_lossy().replace('\\', "/"),
413 bytes,
414 })
415 }
416
417 pub fn report(
420 &self,
421 package: &str,
422 sizes: Vec<FileSize>,
423 signing: Signing,
424 debug: Option<String>,
425 ) -> ExportReport {
426 ExportReport {
427 target: self.target.clone(),
428 profile: self.profile.clone(),
429 version: self.version.clone(),
430 out: self.shown.replace('\\', "/"),
431 package: package.to_owned(),
432 folder: self.base(),
433 sizes,
434 warnings: Vec::new(),
435 signing,
436 debug,
437 beside: Vec::new(),
438 }
439 }
440
441 pub fn copy_beside(
445 &self,
446 place: &dyn Fn(bool) -> PathBuf,
447 ) -> Result<Vec<FileSize>, ExportError> {
448 let mut sizes = Vec::new();
449 for item in &self.beside {
450 let from = self.root.join(&item.path);
451 let is_dir = from.is_dir();
452 let to = place(is_dir).join(beside_name(item)?);
453 if to.exists() {
454 return Err(ExportError::Setup(format!(
455 "`export.beside` ships `{}` as {}, which the package already holds",
456 item.path,
457 to.display()
458 )));
459 }
460 if is_dir {
461 crate::web::copy_tree(&from, &to, &|_| true).map_err(ExportError::Build)?;
462 } else {
463 if let Some(parent) = to.parent() {
464 fs::create_dir_all(parent).map_err(|e| {
465 ExportError::Build(format!("create {}: {e}", parent.display()))
466 })?;
467 }
468 fs::copy(&from, &to).map_err(|e| {
469 ExportError::Build(format!("copy {} to {}: {e}", item.path, to.display()))
470 })?;
471 }
472 sizes.push(self.size(&to)?);
473 }
474 Ok(sizes)
475 }
476
477 pub fn mark_beside(&self, report: &ExportReport, shipped: &mut Shipped) {
480 for size in &report.beside {
481 let on_disk = self.out.join(&size.file);
482 let prefix = format!("{}/", report.folder);
483 let name = size.file.strip_prefix(&prefix).unwrap_or(&size.file);
484 let source = format!("export.beside: {name}");
485 if on_disk.is_dir() {
486 let inner = format!("{name}/");
487 for entry in archive::entries(&on_disk, &inner, &|_| false).unwrap_or_default() {
488 shipped.source(entry.name, disclosure::FileKind::LooseAsset, source.clone());
489 }
490 } else {
491 shipped.source(name, disclosure::FileKind::LooseAsset, source);
492 }
493 }
494 }
495
496 pub fn native_notices(&self, triple: &str, dir: &Path) -> Result<(), ExportError> {
500 let mut works = notices::crates(&self.root, &self.package, triple, &self.turned_on)
501 .map_err(ExportError::Build)?;
502 works.extend(notices::engine_files(Build::Native, &works));
503 works.extend(self.game.iter().cloned());
504 notices::write(dir, &self.title, &works).map_err(ExportError::Build)
505 }
506}
507
508fn package_of(
512 id: &str,
513 named: Option<&str>,
514 game: Option<&GameProgram>,
515 web: Option<&Web>,
516) -> Option<String> {
517 let web_package = web.map(|web| web.package.clone());
518 if id == "web" {
519 return web_package;
520 }
521 named
522 .map(str::to_owned)
523 .or_else(|| game.map(|game| game.package.clone()))
524 .or(web_package)
525}
526
527pub(crate) fn package_and_program(
530 root: &Path,
531 config: &Config,
532) -> Result<(String, String), ExportError> {
533 let named = config
534 .export
535 .as_ref()
536 .and_then(|part| part.package.as_deref());
537 let package = package_of("desktop", named, config.game.as_ref(), config.web.as_ref())
538 .ok_or_else(|| {
539 ExportError::Setup(format!(
540 "{} names no package to run: its `game` part names the game's",
541 crate::project::FILE
542 ))
543 })?;
544 let (_, bins, _) = metadata(root, &package)?;
545 let bin = program(&package, &bins)?;
546 Ok((package, bin))
547}
548
549fn turned_on(part: Option<&ExportPart>, without: &[String]) -> Result<Vec<String>, ExportError> {
552 let listed = part.map_or(&[][..], |part| &part.features);
553 if let Some(unknown) = without.iter().find(|name| !listed.contains(name)) {
554 return Err(ExportError::Setup(format!(
555 "--without {unknown}: `export.features` in {} doesn't list it",
556 crate::project::FILE
557 )));
558 }
559 Ok(listed
560 .iter()
561 .filter(|name| !without.contains(name))
562 .cloned()
563 .collect())
564}
565
566fn present(root: &Path, part: Option<&ExportPart>) -> Result<Vec<Beside>, ExportError> {
569 let mut found = Vec::new();
570 for item in part.map_or(&[][..], |part| &part.beside) {
571 beside_name(item)?;
572 if root.join(&item.path).exists() {
573 found.push(item.clone());
574 } else if !item.optional {
575 return Err(ExportError::Setup(format!(
576 "`export.beside` in {} names `{}`, which isn't there",
577 crate::project::FILE,
578 item.path
579 )));
580 }
581 }
582 Ok(found)
583}
584
585fn in_web_build(item: &Beside, web: &Web) -> bool {
587 [&web.music, &web.voice]
588 .into_iter()
589 .any(|folder| folder.as_deref() == Some(item.path.as_str()))
590}
591
592fn beside_name(item: &Beside) -> Result<String, ExportError> {
594 let name = item.name.as_deref().map_or_else(
595 || Path::new(&item.path).file_name().and_then(|n| n.to_str()),
596 Some,
597 );
598 name.filter(|name| {
599 !name.is_empty() && !name.contains(['/', '\\']) && !matches!(*name, "." | "..")
600 })
601 .map(str::to_owned)
602 .ok_or_else(|| {
603 ExportError::Setup(format!(
604 "`export.beside`'s `{}` has no plain name to ship under",
605 item.path
606 ))
607 })
608}
609
610fn version(app: Option<&crate::project::AppPart>, package: String) -> String {
613 app.and_then(|app| app.version.clone()).unwrap_or(package)
614}
615
616fn metadata(root: &Path, package: &str) -> Result<(String, Vec<String>, Features), ExportError> {
619 let output = Command::new(crate::cargo())
620 .args(["metadata", "--no-deps", "--format-version", "1"])
621 .current_dir(root)
622 .output()
623 .map_err(|e| ExportError::MissingTool(format!("cargo doesn't start: {e}")))?;
624 if !output.status.success() {
625 let error = String::from_utf8_lossy(&output.stderr);
626 return Err(ExportError::Setup(format!(
627 "cargo metadata failed: {}",
628 error.trim()
629 )));
630 }
631 let meta: Metadata = serde_json::from_slice(&output.stdout)
632 .map_err(|e| ExportError::Build(format!("read cargo metadata: {e}")))?;
633 let found = meta
634 .packages
635 .into_iter()
636 .find(|p| p.name == package)
637 .ok_or_else(|| ExportError::Setup(format!("no package `{package}` in the workspace")))?;
638 let bins = found
639 .targets
640 .into_iter()
641 .filter(|t| t.kind.iter().any(|kind| kind == "bin"))
642 .map(|t| t.name)
643 .collect();
644 Ok((found.version, bins, found.features))
645}
646
647fn program(package: &str, bins: &[String]) -> Result<String, ExportError> {
650 if bins.iter().any(|bin| bin == package) {
651 return Ok(package.to_owned());
652 }
653 match bins {
654 [one] => Ok(one.clone()),
655 [] => Err(ExportError::Setup(format!(
656 "the package `{package}` has no program (`[[bin]]`) to export"
657 ))),
658 _ => Err(ExportError::Setup(format!(
659 "the package `{package}` has several programs ({}), and none is named `{package}`",
660 bins.join(", ")
661 ))),
662 }
663}
664
665pub fn no_live_link(args: &[String]) -> Result<(), ExportError> {
668 let features = args
669 .windows(2)
670 .filter(|pair| pair[0] == "--features" || pair[0] == "-F")
671 .flat_map(|pair| pair[1].split([',', ' ']))
672 .chain(
673 args.iter()
674 .filter_map(|arg| arg.strip_prefix("--features=")),
675 );
676 for feature in features {
677 if feature.rsplit('/').next() == Some("link") {
678 return Err(ExportError::Setup(format!(
679 "an export never turns on `{feature}`, the debug build's live link"
680 )));
681 }
682 }
683 Ok(())
684}
685
686fn installed(triple: &str) -> Result<(), ExportError> {
688 let listed = Command::new("rustup")
689 .args(["target", "list", "--installed"])
690 .output();
691 match listed {
692 Ok(output) if output.status.success() => {
693 let text = String::from_utf8_lossy(&output.stdout);
694 if text.lines().any(|line| line.trim() == triple) {
695 Ok(())
696 } else {
697 Err(missing_target(triple))
698 }
699 }
700 _ => Ok(()),
702 }
703}
704
705fn missing_target(triple: &str) -> ExportError {
707 ExportError::MissingTool(format!(
708 "the Rust target {triple} isn't installed: run `rustup target add {triple}`"
709 ))
710}
711
712const SOURCE_DATE_EPOCH: &str = "315532800";
716
717fn tool(program: &str, args: &[&Path], install: &str) -> Result<(), ExportError> {
719 let mut command = Command::new(program);
720 command
721 .args(args)
722 .env("SOURCE_DATE_EPOCH", SOURCE_DATE_EPOCH);
723 let status = command.status().map_err(|e| {
724 if e.kind() == std::io::ErrorKind::NotFound {
725 ExportError::MissingTool(format!("{program} isn't installed: {install}"))
726 } else {
727 ExportError::Build(format!("{program} doesn't start: {e}"))
728 }
729 })?;
730 if status.success() {
731 Ok(())
732 } else {
733 let args: Vec<String> = args.iter().map(|a| a.display().to_string()).collect();
734 Err(ExportError::Build(format!(
735 "{program} {} exited with {status}",
736 args.join(" ")
737 )))
738 }
739}
740
741fn fresh(dir: &Path) -> Result<(), ExportError> {
743 if dir.exists() {
744 fs::remove_dir_all(dir)
745 .map_err(|e| ExportError::Build(format!("clear {}: {e}", dir.display())))?;
746 }
747 fs::create_dir_all(dir)
748 .map_err(|e| ExportError::Build(format!("create {}: {e}", dir.display())))
749}
750
751fn write(path: &Path, text: &str) -> Result<(), ExportError> {
753 fs::write(path, text).map_err(|e| ExportError::Build(format!("write {}: {e}", path.display())))
754}
755
756fn folder_size(dir: &Path) -> Result<u64, ExportError> {
758 let entries = archive::entries(dir, "", &|_| false).map_err(ExportError::Build)?;
759 entries
760 .iter()
761 .map(|entry| {
762 fs::metadata(&entry.path)
763 .map(|meta| meta.len())
764 .map_err(|e| ExportError::Build(format!("stat {}: {e}", entry.path.display())))
765 })
766 .sum()
767}
768
769#[cfg(test)]
770mod tests;