//! Project automation, run with `cargo xtask `. //! //! ```text //! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug] //! cargo xtask watch [pack options] //! ``` mod watch; use std::{ env, error::Error, fs::{self, File}, io::{self, BufRead, BufReader, BufWriter, Write}, path::{Path, PathBuf}, process::{Command, ExitCode, Stdio}, }; type Result> = std::result::Result; 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)), Some("watch") => PackOptions::parse(args).and_then(|opts| watch::run(&opts)), 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 \n\n\ commands:\n \ 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 \ --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 { 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, format: Option, out_dir: Option, debug: bool, } impl PackOptions { fn parse(mut args: impl Iterator) -> Result { 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 { 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> { 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> { let mut files = Vec::new(); let mut entries: Vec<_> = fs::read_dir(dir)?.collect::>()?; 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(); } }