Skip to main content

shuyi_cli/
export.rs

1//! The exporters `export` runs ([`crate::contract::export`]): one per
2//! target, each building the game with the target's Rust triple and
3//! writing its package ([`Exporter`]). `windows` writes a zip, `linux` a
4//! `tar.gz`, `macos` a folder holding a signed `.app`, `web` a folder and
5//! its zip, and `playdate` a `.pdx` and its zip. Every package carries
6//! `NOTICES.txt` ([`notices`]), and
7//! the Windows and macOS ones a note for players on opening an unsigned
8//! program (`HOW-TO-OPEN.txt`).
9//!
10//! A `--release` build has nothing of the build machine: its paths are
11//! remapped, and the built binary is searched for them ([`paths`]). On
12//! Windows and Linux it keeps its line tables in `<package>.debug`, beside
13//! the package and never in it, and the package's copy is stripped of
14//! them with `objcopy --strip-debug` (which moves no code, so a crash's
15//! offsets name the same lines).
16//!
17//! `shuyi.ron`'s `export` part names the features a build turns on, never the
18//! debug build's live link (`link`), and the files and folders copied into
19//! the package ([`ExportCx::copy_beside`]). A game with no such part gets a
20//! build with no feature and a package with nothing else in it.
21//!
22//! Every final package is then read back and written down, and held to the
23//! game's `disclosure` part if it has one ([`disclosure`]).
24
25pub 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
47/// A target's exporter.
48pub trait Exporter: Sync {
49    /// The target's id: `shuyi export <id>`.
50    fn id(&self) -> &'static str;
51
52    /// The target profile its check runs with (`targets/<profile>.ron`).
53    fn profile(&self) -> &'static str;
54
55    /// The Rust triples it builds for.
56    fn triples(&self) -> &'static [&'static str];
57
58    /// Whether it packages a program of the game's package; otherwise it
59    /// builds the package's library, and the package needs no `[[bin]]`.
60    fn builds_a_program(&self) -> bool {
61        true
62    }
63
64    /// The `cargo` arguments of each build it runs.
65    fn cargo_args(&self, cx: &ExportCx) -> Vec<Vec<String>>;
66
67    /// Whether `shuyi.ron`'s `export` part applies to it.
68    fn takes_export_part(&self) -> bool {
69        true
70    }
71
72    /// What it needs of the game's tools before it builds, run where they
73    /// are at hand: by default nothing.
74    fn prepare(
75        &self,
76        _cx: &ExportCx,
77        _project: &Project,
78        _game: Option<&dyn GameTools>,
79    ) -> Result<(), ExportError> {
80        Ok(())
81    }
82
83    /// Builds and writes the package into `cx.out`.
84    fn export(&self, cx: &ExportCx) -> Result<ExportReport, ExportError>;
85
86    /// What the export that answered `report` wrote and where, for the
87    /// inspection after it. By default the folder and archive `report` names
88    /// and no file known, so a target that doesn't say holds only undeclared
89    /// files.
90    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    /// Runs the package `export` wrote: by default there is no run step.
100    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
108/// The exporters, by target id.
109static EXPORTERS: [&dyn Exporter; 5] = [
110    &desktop::WINDOWS,
111    &desktop::LINUX,
112    &desktop::MACOS,
113    &web::WEB,
114    &playdate::PLAYDATE,
115];
116
117/// Every exporter.
118pub fn exporters() -> &'static [&'static dyn Exporter] {
119    &EXPORTERS
120}
121
122/// The exporter of target `id`.
123pub fn find(id: &str) -> Option<&'static dyn Exporter> {
124    EXPORTERS
125        .iter()
126        .copied()
127        .find(|exporter| exporter.id() == id)
128}
129
130/// The targets with an exporter.
131pub fn ids() -> Vec<&'static str> {
132    EXPORTERS.iter().map(|exporter| exporter.id()).collect()
133}
134
135/// Why an export failed.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum ExportError {
138    /// A tool is missing: the message says how to install it.
139    MissingTool(String),
140    /// `shuyi.ron` or the game's package lacks what the export needs.
141    Setup(String),
142    /// The build, or writing the package, failed.
143    Build(String),
144    /// The built binary holds a path of the build machine.
145    BuildPath(String),
146    /// The target has no run step yet.
147    NoRunStep(String),
148}
149
150impl ExportError {
151    /// Its problem code.
152    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    /// What went wrong.
163    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/// What an exporter works from.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct ExportCx {
177    /// The target.
178    pub target: String,
179    /// Its profile.
180    pub profile: String,
181    /// The project's root.
182    pub root: PathBuf,
183    /// The root of the workspace it builds in.
184    pub workspace: PathBuf,
185    /// Cargo's target folder.
186    pub target_dir: PathBuf,
187    /// The export's folder, made.
188    pub out: PathBuf,
189    /// The export's folder as the game shows it.
190    pub shown: String,
191    /// Whether it is a release build.
192    pub release: bool,
193    /// The game's package.
194    pub package: String,
195    /// The program it builds.
196    pub bin: String,
197    /// The game's version: `shuyi.ron`'s `app.version` when it sets one,
198    /// else the package's.
199    pub version: String,
200    /// The game's title, for `NOTICES.txt`.
201    pub title: String,
202    /// The game's own works for `NOTICES.txt`.
203    pub game: Vec<Work>,
204    /// The game's web build, if it has one.
205    pub web: Option<Web>,
206    /// Who the game is, if `shuyi.ron` says.
207    pub app: Option<AppPart>,
208    /// The cargo features of the game's package.
209    pub features: Vec<String>,
210    /// The features its build turns on.
211    pub turned_on: Vec<String>,
212    /// What ships beside the program.
213    pub beside: Vec<Beside>,
214}
215
216/// What a run step works from.
217pub struct RunCx<'a> {
218    /// The export's folder.
219    pub out: &'a Path,
220    /// What the export made.
221    pub report: &'a ExportReport,
222}
223
224/// A package's `[features]`: each feature and what it turns on.
225type Features = BTreeMap<String, Vec<String>>;
226
227/// `cargo metadata --no-deps`, the fields read here.
228#[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    /// The context of exporting `project` with `exporter` as `input`
249    /// asks: the folder made, the game's package found with its version
250    /// and program (`cargo metadata --no-deps`).
251    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    /// The package's name without its extension:
326    /// `<program>-<version>-<target>`.
327    pub fn base(&self) -> String {
328        format!("{}-{}-{}", self.bin, self.version, self.target)
329    }
330
331    /// `cargo build`'s arguments for the program and `triple`.
332    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    /// The path of the program `cargo` builds for `triple`.
347    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    /// The paths a release package must not hold.
358    pub fn needles(&self) -> Vec<PathBuf> {
359        paths::needles(&[&self.root, &self.workspace])
360    }
361
362    /// Refuses `file` if it holds a path of the build machine (a release
363    /// build only).
364    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    /// Builds the program for `triple`, with its line tables kept and not
372    /// stripped if `keep_debug` and it is a release build, and returns the
373    /// program's path.
374    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    /// `file`'s size, named from the export's folder.
402    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    /// The report of a package `package` made from `folder` (both in the
418    /// export's folder), with `sizes` and `signing`.
419    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    /// Copies what ships beside the program, each into the folder `place`
442    /// picks for it from whether it is a folder, under its name there, and
443    /// returns what it copied. A name the package already holds is refused.
444    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    /// Says in `shipped` that what `report` lists as shipped beside the
478    /// program is the game's own, for the exposure report.
479    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    /// Writes `NOTICES.txt` into `dir` for a native build for `triple`:
497    /// the crates compiled in, the engine's files they compile in, and the
498    /// game's own works.
499    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
508/// The package an export of target `id` builds: the web build's on `web`;
509/// otherwise the `export` part's, the `game` part's or the web build's, the
510/// first there is.
511fn 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
527/// The package and program of the project at `root` that `config` says runs
528/// the game on a desktop, as `export` builds it.
529pub(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
549/// The features a build turns on: what `part` lists but `without`. Leaving
550/// out one it doesn't list is refused.
551fn 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
566/// What `part` ships that is there: one that isn't refuses the export,
567/// unless it is optional.
568fn 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
585/// Whether the web build already ships `item`: its `music` or `voice` folder.
586fn 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
592/// The name `item` has in the package: one plain name, never a path.
593fn 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
610/// The game's version: `app`'s, when it sets one, else its package's,
611/// `package`.
612fn version(app: Option<&crate::project::AppPart>, package: String) -> String {
613    app.and_then(|app| app.version.clone()).unwrap_or(package)
614}
615
616/// The version, the programs and the features of `package`, by `cargo
617/// metadata --no-deps` in `root`.
618fn 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
647/// The program of `package` to export among its `bins`: the one named as
648/// the package, else its only one.
649fn 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
665/// Refuses a build whose `args` turn on a feature named `link`, the debug
666/// build's live link (ADR-0035).
667pub 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
686/// Fails with how to add `triple` if rustup is here and doesn't have it.
687fn 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        // No rustup: cargo says what it lacks.
701        _ => Ok(()),
702    }
703}
704
705/// The error of a Rust target that isn't installed.
706fn 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
712/// The time a packaging tool may write into a file, the archives' day
713/// (1980-01-01): `objcopy` would stamp a Windows program with the time it ran,
714/// and the same input is to give the same package.
715const SOURCE_DATE_EPOCH: &str = "315532800";
716
717/// Runs `program` with `args`, failing with `install` if it isn't there.
718fn 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
741/// `dir`, emptied: removed if it is there, then made.
742fn 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
751/// Writes `text` to `path`.
752fn write(path: &Path, text: &str) -> Result<(), ExportError> {
753    fs::write(path, text).map_err(|e| ExportError::Build(format!("write {}: {e}", path.display())))
754}
755
756/// The bytes of the files under `dir`.
757fn 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;