2026-10-18 18:08:45 +00:00
|
|
|
//! Project automation, run with `cargo xtask <command>`.
|
|
|
|
|
//!
|
|
|
|
|
//! ```text
|
|
|
|
|
//! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug]
|
2026-10-21 20:30:03 +00:00
|
|
|
//! cargo xtask watch [pack options]
|
2026-10-18 18:08:45 +00:00
|
|
|
//! ```
|
|
|
|
|
|
2026-10-21 20:30:03 +00:00
|
|
|
mod watch;
|
|
|
|
|
|
2026-10-18 18:08:45 +00:00
|
|
|
use std::{
|
|
|
|
|
env,
|
|
|
|
|
error::Error,
|
|
|
|
|
fs::{self, File},
|
|
|
|
|
io::{self, BufRead, BufReader, BufWriter, Write},
|
|
|
|
|
path::{Path, PathBuf},
|
|
|
|
|
process::{Command, ExitCode, Stdio},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
|
|
|
|
|
|
|
|
|
|
const PACKAGE_NAME: &str = "station";
|
|
|
|
|
/// `(package, binary)` pairs shipped in the archive.
|
|
|
|
|
const BINARIES: [(&str, &str); 2] = [("game_client", "game"), ("game_server", "game-server")];
|
|
|
|
|
/// Files and directories (relative to the workspace root) shipped next to the binaries.
|
|
|
|
|
const EXTRA_FILES: [&str; 3] = ["assets", "README.md", "CHANGELOG.md"];
|
|
|
|
|
|
|
|
|
|
fn main() -> ExitCode {
|
|
|
|
|
let mut args = env::args().skip(1);
|
|
|
|
|
let result = match args.next().as_deref() {
|
|
|
|
|
Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)),
|
2026-10-21 20:30:03 +00:00
|
|
|
Some("watch") => PackOptions::parse(args).and_then(|opts| watch::run(&opts)),
|
2026-10-18 18:08:45 +00:00
|
|
|
Some("-h" | "--help") | None => {
|
|
|
|
|
print_help();
|
|
|
|
|
return ExitCode::SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
Some(other) => Err(format!("unknown command `{other}`, see `cargo xtask --help`").into()),
|
|
|
|
|
};
|
|
|
|
|
match result {
|
|
|
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
eprintln!("error: {err}");
|
|
|
|
|
ExitCode::FAILURE
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_help() {
|
|
|
|
|
println!(
|
|
|
|
|
"usage: cargo xtask <command>\n\n\
|
|
|
|
|
commands:\n \
|
2026-10-21 20:30:03 +00:00
|
|
|
pack build the client and server and pack them with their assets\n \
|
|
|
|
|
watch pack after sources are unchanged for 10 seconds; pause with dist/NOREBUILD\n\n\
|
|
|
|
|
pack/watch options:\n \
|
2026-10-18 18:08:45 +00:00
|
|
|
--target TRIPLE cross-compile for TRIPLE (default: host)\n \
|
|
|
|
|
--format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \
|
|
|
|
|
--out-dir DIR output directory (default: dist)\n \
|
|
|
|
|
--debug pack a debug build instead of the stripped `dist` profile"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
|
|
|
enum Format {
|
|
|
|
|
Zip,
|
|
|
|
|
TarGz,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Format {
|
|
|
|
|
fn parse(s: &str) -> Result<Self> {
|
|
|
|
|
match s {
|
|
|
|
|
"zip" => Ok(Self::Zip),
|
|
|
|
|
"tar.gz" | "tgz" => Ok(Self::TarGz),
|
|
|
|
|
_ => Err(format!("unknown archive format `{s}` (expected zip or tar.gz)").into()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn default_for(target: &str) -> Self {
|
|
|
|
|
if target.contains("windows") {
|
|
|
|
|
Self::Zip
|
|
|
|
|
} else {
|
|
|
|
|
Self::TarGz
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn extension(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Zip => "zip",
|
|
|
|
|
Self::TarGz => "tar.gz",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
struct PackOptions {
|
|
|
|
|
target: Option<String>,
|
|
|
|
|
format: Option<Format>,
|
|
|
|
|
out_dir: Option<PathBuf>,
|
|
|
|
|
debug: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PackOptions {
|
|
|
|
|
fn parse(mut args: impl Iterator<Item = String>) -> Result<Self> {
|
|
|
|
|
let mut opts = Self::default();
|
|
|
|
|
while let Some(arg) = args.next() {
|
|
|
|
|
let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value"));
|
|
|
|
|
match arg.as_str() {
|
|
|
|
|
"--target" => opts.target = Some(value("--target")?),
|
|
|
|
|
"--format" => opts.format = Some(Format::parse(&value("--format")?)?),
|
|
|
|
|
"--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()),
|
|
|
|
|
"--debug" => opts.debug = true,
|
|
|
|
|
"-h" | "--help" => {
|
|
|
|
|
print_help();
|
|
|
|
|
std::process::exit(0);
|
|
|
|
|
}
|
|
|
|
|
other => return Err(format!("unknown pack option `{other}`").into()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(opts)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn workspace_root() -> PathBuf {
|
|
|
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
|
|
|
.parent()
|
|
|
|
|
.expect("xtask lives inside the workspace")
|
|
|
|
|
.to_path_buf()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn cargo() -> Command {
|
|
|
|
|
Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn host_target() -> Result<String> {
|
|
|
|
|
let output = Command::new(env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
|
|
|
|
|
.arg("-vV")
|
|
|
|
|
.output()?;
|
|
|
|
|
String::from_utf8(output.stdout)?
|
|
|
|
|
.lines()
|
|
|
|
|
.find_map(|line| line.strip_prefix("host: "))
|
|
|
|
|
.map(str::to_owned)
|
|
|
|
|
.ok_or_else(|| "could not determine the host target from `rustc -vV`".into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn pack(opts: &PackOptions) -> Result<()> {
|
|
|
|
|
let root = workspace_root();
|
|
|
|
|
let target = match &opts.target {
|
|
|
|
|
Some(target) => target.clone(),
|
|
|
|
|
None => host_target()?,
|
|
|
|
|
};
|
|
|
|
|
let format = opts.format.unwrap_or_else(|| Format::default_for(&target));
|
|
|
|
|
let out_dir = root.join(opts.out_dir.as_deref().unwrap_or(Path::new("dist")));
|
|
|
|
|
let name = format!("{PACKAGE_NAME}-{}-{target}", env!("CARGO_PKG_VERSION"));
|
|
|
|
|
|
|
|
|
|
let executables = build(&root, opts.target.as_deref(), opts.debug)?;
|
|
|
|
|
|
|
|
|
|
// The staging directory is kept so CI can upload it without double-zipping.
|
|
|
|
|
let stage = out_dir.join(&name);
|
|
|
|
|
if stage.exists() {
|
|
|
|
|
fs::remove_dir_all(&stage)?;
|
|
|
|
|
}
|
|
|
|
|
fs::create_dir_all(&stage)?;
|
|
|
|
|
for exe in &executables {
|
|
|
|
|
fs::copy(
|
|
|
|
|
exe,
|
|
|
|
|
stage.join(exe.file_name().expect("executable has a file name")),
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
for extra in EXTRA_FILES {
|
|
|
|
|
copy_recursive(&root.join(extra), &stage.join(extra))?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let archive = out_dir.join(format!("{name}.{}", format.extension()));
|
|
|
|
|
write_archive(&stage, &name, &archive, format)?;
|
|
|
|
|
println!("packed {}", archive.display());
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds the shipped binaries and returns their paths, as reported by cargo.
|
|
|
|
|
fn build(root: &Path, target: Option<&str>, debug: bool) -> Result<Vec<PathBuf>> {
|
|
|
|
|
let mut cmd = cargo();
|
|
|
|
|
cmd.current_dir(root).args([
|
|
|
|
|
"build",
|
|
|
|
|
"--locked",
|
|
|
|
|
"--message-format=json-render-diagnostics",
|
|
|
|
|
]);
|
|
|
|
|
cmd.args(["--profile", if debug { "dev" } else { "dist" }]);
|
|
|
|
|
if let Some(target) = target {
|
|
|
|
|
cmd.args(["--target", target]);
|
|
|
|
|
}
|
|
|
|
|
for (package, bin) in BINARIES {
|
|
|
|
|
cmd.args(["-p", package, "--bin", bin]);
|
|
|
|
|
}
|
|
|
|
|
let mut child = cmd.stdout(Stdio::piped()).spawn()?;
|
|
|
|
|
|
|
|
|
|
let mut executables = Vec::new();
|
|
|
|
|
for line in BufReader::new(child.stdout.take().expect("stdout is piped")).lines() {
|
|
|
|
|
let msg: serde_json::Value = serde_json::from_str(&line?)?;
|
|
|
|
|
if msg["reason"] == "compiler-artifact"
|
|
|
|
|
&& let Some(exe) = msg["executable"].as_str()
|
|
|
|
|
&& BINARIES
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|(_, bin)| msg["target"]["name"] == *bin)
|
|
|
|
|
{
|
|
|
|
|
executables.push(PathBuf::from(exe));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !child.wait()?.success() {
|
|
|
|
|
return Err("cargo build failed".into());
|
|
|
|
|
}
|
|
|
|
|
if executables.len() != BINARIES.len() {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"expected {} executables, cargo reported {executables:?}",
|
|
|
|
|
BINARIES.len()
|
|
|
|
|
)
|
|
|
|
|
.into());
|
|
|
|
|
}
|
|
|
|
|
Ok(executables)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn copy_recursive(from: &Path, to: &Path) -> io::Result<()> {
|
|
|
|
|
if from.is_dir() {
|
|
|
|
|
fs::create_dir_all(to)?;
|
|
|
|
|
for entry in fs::read_dir(from)? {
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
copy_recursive(&entry.path(), &to.join(entry.file_name()))?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
fs::copy(from, to).map(|_| ())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Files below `dir` as `(path, archive path with '/' separators)`, sorted for reproducible archives.
|
|
|
|
|
fn collect_files(dir: &Path, prefix: &str) -> io::Result<Vec<(PathBuf, String)>> {
|
|
|
|
|
let mut files = Vec::new();
|
|
|
|
|
let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<io::Result<_>>()?;
|
|
|
|
|
entries.sort_by_key(|e| e.file_name());
|
|
|
|
|
for entry in entries {
|
|
|
|
|
let name = format!("{prefix}/{}", entry.file_name().to_string_lossy());
|
|
|
|
|
if entry.file_type()?.is_dir() {
|
|
|
|
|
files.extend(collect_files(&entry.path(), &name)?);
|
|
|
|
|
} else {
|
|
|
|
|
files.push((entry.path(), name));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(files)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_executable(archive_path: &str) -> bool {
|
|
|
|
|
let file = archive_path.rsplit('/').next().unwrap_or(archive_path);
|
|
|
|
|
BINARIES
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|(_, bin)| file == *bin || file == format!("{bin}.exe"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Packs the contents of `dir` into `archive`, below a top-level directory `prefix`.
|
|
|
|
|
fn write_archive(dir: &Path, prefix: &str, archive: &Path, format: Format) -> Result<()> {
|
|
|
|
|
let files = collect_files(dir, prefix)?;
|
|
|
|
|
let out = BufWriter::new(File::create(archive)?);
|
|
|
|
|
match format {
|
|
|
|
|
Format::Zip => {
|
|
|
|
|
let mut zip = zip::ZipWriter::new(out);
|
|
|
|
|
for (path, name) in files {
|
|
|
|
|
let mode = if is_executable(&name) { 0o755 } else { 0o644 };
|
|
|
|
|
let options = zip::write::SimpleFileOptions::default()
|
|
|
|
|
.compression_method(zip::CompressionMethod::Deflated)
|
|
|
|
|
.unix_permissions(mode);
|
|
|
|
|
zip.start_file(name, options)?;
|
|
|
|
|
io::copy(&mut File::open(path)?, &mut zip)?;
|
|
|
|
|
}
|
|
|
|
|
zip.finish()?.flush()?;
|
|
|
|
|
}
|
|
|
|
|
Format::TarGz => {
|
|
|
|
|
let gz = flate2::write::GzEncoder::new(out, flate2::Compression::default());
|
|
|
|
|
let mut tar = tar::Builder::new(gz);
|
|
|
|
|
for (path, name) in files {
|
|
|
|
|
let mut header = tar::Header::new_gnu();
|
|
|
|
|
header.set_size(fs::metadata(&path)?.len());
|
|
|
|
|
header.set_mode(if is_executable(&name) { 0o755 } else { 0o644 });
|
|
|
|
|
header.set_mtime(0);
|
|
|
|
|
header.set_cksum();
|
|
|
|
|
tar.append_data(&mut header, &name, File::open(&path)?)?;
|
|
|
|
|
}
|
|
|
|
|
tar.into_inner()?.finish()?.flush()?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn temp_dir(name: &str) -> PathBuf {
|
|
|
|
|
let dir = env::temp_dir().join(format!("xtask-test-{name}-{}", std::process::id()));
|
|
|
|
|
let _ = fs::remove_dir_all(&dir);
|
|
|
|
|
fs::create_dir_all(dir.join("assets/data")).unwrap();
|
|
|
|
|
fs::write(dir.join("game"), b"binary").unwrap();
|
|
|
|
|
fs::write(dir.join("assets/data/items.rsn"), b"items: []").unwrap();
|
|
|
|
|
dir
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn default_format_follows_target() {
|
|
|
|
|
assert_eq!(Format::default_for("x86_64-pc-windows-msvc"), Format::Zip);
|
|
|
|
|
assert_eq!(Format::default_for("x86_64-pc-windows-gnu"), Format::Zip);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
Format::default_for("x86_64-unknown-linux-gnu"),
|
|
|
|
|
Format::TarGz
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_pack_options() {
|
|
|
|
|
let args = ["--target", "x", "--format", "zip", "--debug"].map(String::from);
|
|
|
|
|
let opts = PackOptions::parse(args.into_iter()).unwrap();
|
|
|
|
|
assert_eq!(opts.target.as_deref(), Some("x"));
|
|
|
|
|
assert_eq!(opts.format, Some(Format::Zip));
|
|
|
|
|
assert!(opts.debug);
|
|
|
|
|
assert!(PackOptions::parse(["--target".to_owned()].into_iter()).is_err());
|
|
|
|
|
assert!(PackOptions::parse(["--bogus".to_owned()].into_iter()).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn zip_contains_prefixed_files_with_permissions() {
|
|
|
|
|
let dir = temp_dir("zip");
|
|
|
|
|
let archive = dir.with_extension("zip");
|
|
|
|
|
write_archive(&dir, "pkg", &archive, Format::Zip).unwrap();
|
|
|
|
|
|
|
|
|
|
let mut zip = zip::ZipArchive::new(File::open(&archive).unwrap()).unwrap();
|
|
|
|
|
let mut names: Vec<_> = zip.file_names().map(|n| n.unwrap().into_owned()).collect();
|
|
|
|
|
names.sort();
|
|
|
|
|
assert_eq!(names, ["pkg/assets/data/items.rsn", "pkg/game"]);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
zip.by_name("pkg/game").unwrap().unix_mode().unwrap() & 0o777,
|
|
|
|
|
0o755
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
fs::remove_file(&archive).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tar_gz_contains_prefixed_files_with_permissions() {
|
|
|
|
|
let dir = temp_dir("tar");
|
|
|
|
|
let archive = dir.with_extension("tar.gz");
|
|
|
|
|
write_archive(&dir, "pkg", &archive, Format::TarGz).unwrap();
|
|
|
|
|
|
|
|
|
|
let gz = flate2::read::GzDecoder::new(File::open(&archive).unwrap());
|
|
|
|
|
let mut tar = tar::Archive::new(gz);
|
|
|
|
|
let entries: Vec<_> = tar
|
|
|
|
|
.entries()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.map(|e| {
|
|
|
|
|
let e = e.unwrap();
|
|
|
|
|
(
|
|
|
|
|
e.path().unwrap().to_string_lossy().into_owned(),
|
|
|
|
|
e.header().mode().unwrap(),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
entries,
|
|
|
|
|
[
|
|
|
|
|
("pkg/assets/data/items.rsn".to_owned(), 0o644),
|
|
|
|
|
("pkg/game".to_owned(), 0o755)
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
fs::remove_file(&archive).unwrap();
|
|
|
|
|
}
|
|
|
|
|
}
|