use std::{ ffi::OsString, process::{Command, ExitStatus}, }; use clap::{builder::TypedValueParser, Parser, Subcommand}; use strum::VariantNames; mod enums; mod generate; mod stationpedia; /// Helper program to start ic10emu and website. /// /// Can be invoked as `cargo xtask ` #[derive(Debug, Parser)] #[command(bin_name = "cargo xtask")] struct Args { /// Package manager to use #[arg(long, global = true, default_value = "pnpm")] manager: String, /// wasm-pack executable #[arg(long, global = true, default_value = "wasm-pack")] wasm_pack: String, #[command(subcommand)] task: Task, } const PACKAGES: &[&str] = &["ic10lsp_wasm", "ic10emu_wasm"]; const VALID_GENERATE_TYPE: &[&str] = &["enums", "instructions", "database"]; const DEFAULT_GENERATE: &[&str] = &["enums"]; fn parse_generate_modules(s: &str) -> Result { if !VALID_GENERATE_TYPE.contains(&s) { let valid_str = VALID_GENERATE_TYPE.join(", "); return Err(format!( "{s} is not a valid generate module. One of: {valid_str}" )); } Ok(s.to_string()) } #[derive( Debug, Clone, PartialEq, strum::EnumString, strum::Display, strum::VariantNames, strum::AsRefStr, )] enum VersionBumpType { #[strum(serialize = "patch")] Patch, #[strum(serialize = "minor")] Minor, #[strum(serialize = "major")] Major, } #[derive(Debug, Subcommand)] enum Task { /// Build the packages Build { /// Build in release mode #[arg(long)] release: bool, /// Packages to build #[arg(long, short = 'p', default_values = PACKAGES)] packages: Vec, /// Additional arguments to pass to wasm-pack, use another `--` to pass to cargo build #[arg(last = true, default_values = ["--","-q"])] // #[arg(last = true)] rest: Vec, }, /// Start the server /// /// This does not build the packages, use `build` first Start {}, /// Runs production page under 'www/dist', Run `build` first. Deploy {}, /// bump the cargo.toml and package,json versions Version { #[arg(default_value = "patch", value_parser = clap::builder::PossibleValuesParser::new(VersionBumpType::VARIANTS).map(|s| s.parse::().unwrap()))] bump: VersionBumpType, }, /// Update changelog Changelog {}, /// Generate modules and databases from extracted stationeers data Generate { #[arg(long, short = 'm', value_delimiter = ',', default_values = DEFAULT_GENERATE, value_parser = parse_generate_modules)] modules: Vec, #[arg()] /// Path to Stationeers installation. Used to locate "Stationpedia.json" and "Enums.json" /// generated by https://github.com/Ryex/StationeersStationpediaExtractor /// Otherwise looks for both files in `` or `/data`. /// Can also point directly at a folder containing the two files. path: Option, }, /// Update changelog and tag release, optionally bump the version at the same time Tag { #[arg(value_parser = clap::builder::PossibleValuesParser::new(VersionBumpType::VARIANTS).map(|s| s.parse::().unwrap()))] bump: Option, }, } #[derive(thiserror::Error)] enum Error { #[error("building package {0} failed. Command: `{1}` Status code {2}")] BuildFailed(String, String, std::process::ExitStatus), #[error("failed to run command `{0}`")] Command(String, #[source] std::io::Error), #[error("can not find `Stationpedia.json` and/or `Enums.json` at `{0}`")] BadStationeersPath(std::path::PathBuf), } impl std::fmt::Debug for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use std::error::Error; use std::fmt::*; write!(f, "Error: {}", self)?; let mut err: &dyn Error = self; while let Some(cause) = err.source() { write!(f, "\nCaused by: ")?; Display::fmt(&cause, f)?; err = cause; } Ok(()) } } const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION"); fn main() -> color_eyre::Result<()> { color_eyre::install()?; let args = Args::parse(); let workspace = { let out = Command::new("cargo") .arg("metadata") .arg("--no-deps") .arg("--format-version=1") .output() .map_err(|e| Error::Command("cargo metadata".to_string(), e))?; let s = std::str::from_utf8(&out.stdout).unwrap(); let Some((_, s)) = s.split_once(r#"workspace_root":""#) else { panic!("couldn't find workspace root"); }; let Some((path, _)) = s.split_once("\",") else { panic!("couldn't find workspace root"); }; std::path::PathBuf::from(path) }; match &args.task { Task::Build { release, packages, rest, } => { build(&args, packages, *release, &workspace, rest)?; } Task::Start {} => { start_server(&args, &workspace)?; } Task::Deploy {} => { deploy_web(&args, &workspace)?; } Task::Version { bump } => { bump_version(&args, &workspace, bump)?; } Task::Changelog {} => { update_changelog(&workspace)?; } Task::Generate { modules, path } => { generate_data(&workspace, modules, path)?; } Task::Tag { bump } => { tag_release(&args, &workspace, bump.as_ref())?; } } Ok(()) } fn build_command(program: S, args: I, working_dir: P) -> Command where S: AsRef, A: AsRef, I: IntoIterator, P: AsRef, { let mut cmd = Command::new(program); cmd.current_dir(working_dir); cmd.args(args); cmd } fn run_command( program: S, args: I, working_dir: P, ) -> Result<(ExitStatus, Command), Error> where S: AsRef, A: AsRef, I: IntoIterator, P: AsRef, { let mut cmd = build_command(program, args, working_dir); let status = cmd .status() .map_err(|e| Error::Command(format!("{}", cmd.get_program().to_string_lossy()), e))?; Ok((status, cmd)) } fn start_server(args: &Args, workspace: &std::path::Path) -> Result<(), Error> { pnpm_install(&args, &workspace)?; eprintln!("Starting server"); run_command(&args.manager, ["run", "start"], &workspace.join("www"))?; Ok(()) } fn deploy_web(args: &Args, workspace: &std::path::Path) -> Result<(), Error> { pnpm_install(&args, &workspace)?; eprintln!("Production Build"); run_command(&args.manager, ["run", "build"], &workspace.join("www"))?; Ok(()) } fn bump_version( args: &Args, workspace: &std::path::Path, bump: &VersionBumpType, ) -> Result<(), Error> { run_command( "cargo", ["set-version", "--bump", bump.as_ref()], &workspace, )?; run_command( &args.manager, ["version", bump.as_ref()], &workspace.join("www"), )?; Ok(()) } fn update_changelog(workspace: &std::path::Path) -> Result<(), Error> { run_command( "git-changelog", [ "-io", "CHANGELOG.md", "-t", "path:CHANGELOG.md.jinja", "-c", "conventional", "--sections", "chore,feat,fix,perf,revert", "--bump", VERSION.unwrap_or("auto"), "--parse-refs", "--trailers", ], &workspace, )?; Ok(()) } fn build + std::fmt::Debug + std::fmt::Display>( args: &Args, packages: &[P], release: bool, workspace: &std::path::Path, rest: &[std::ffi::OsString], ) -> Result<(), Error> { if packages.is_empty() { panic!("no package(s) specified") } eprintln!("Building packages: {:?}, release: {}", packages, release); for package in packages { eprintln!("Building package: {}", package); eprintln!( "Running command: {} build {} {} {}", &args.wasm_pack, if release { "--release" } else { "--dev" }, package, rest.join(std::ffi::OsStr::new(" ")).to_string_lossy(), ); let cmd_args: [std::ffi::OsString; 3] = [ "build".into(), if release { "--release".into() } else { "--dev".into() }, package.into(), ]; let (status, cmd) = run_command(&args.wasm_pack, [&cmd_args, rest].concat(), workspace)?; if status.success() { eprintln!("{} built successfully", package); } else { return Err(Error::BuildFailed( package.to_string(), format!("{cmd:?}"), status, )); } } Ok(()) } fn pnpm_install(args: &Args, workspace: &std::path::Path) -> Result { eprintln!("Running `pnpm install`"); let (status, _) = run_command(&args.manager, ["install"], &workspace.join("www"))?; Ok(status) } fn generate_data( workspace: &std::path::Path, modules: &Vec, path: &Option, ) -> color_eyre::Result<()> { let path = match path { Some(path) => { let mut path = std::path::PathBuf::from(path); if path.exists() && path .parent() .and_then(|p| p.file_name()) .is_some_and(|p| p == "Stationeers") && path.file_name().is_some_and(|name| { (std::env::consts::OS == "windows" && name == "rocketstation.exe") || (name == "rocketstation") || (name == "rocketstation_Data") }) { path = path.parent().unwrap().to_path_buf(); } if path.is_dir() && path.file_name().is_some_and(|name| name == "Stationeers") && path.join("Stationpedia").join("Stationpedia.json").exists() { path = path.join("Stationpedia"); } if path.is_file() && path .file_name() .is_some_and(|name| name == "Stationpedia.json") { path = path.parent().unwrap().to_path_buf(); } path } None => { let mut path = workspace.to_path_buf(); if path.join("data").join("Stationpedia.json").exists() && path.join("data").join("Enums.json").exists() { path = path.join("data") } path } }; if path.is_dir() && path.join("Stationpedia.json").exists() && path.join("Enums.json").exists() { generate::generate( &path, &workspace, &modules.iter().map(String::as_str).collect::>(), )?; } else { return Err(Error::BadStationeersPath(path).into()); } Ok(()) } fn tag_release( args: &Args, workspace: &std::path::Path, bump: Option<&VersionBumpType>, ) -> Result<(), Error> { let mut version = semver::Version::parse(VERSION.expect("package version to be set")) .expect("package version to parse"); if let Some(bump) = bump { eprintln!("Bumping {} version", bump.as_ref()); bump_version(args, workspace, bump)?; match bump { &VersionBumpType::Major => { version.major += 1; version.minor = 0; version.patch = 0; } &VersionBumpType::Minor => { version.minor += 1; version.patch = 0; } &VersionBumpType::Patch => { version.patch += 1; } } } update_changelog(workspace)?; let tag_ver = format!("v{}", version); run_command("git", ["add", "."], &workspace)?; run_command( "git", ["commit", "-m", &format!("tagging version {}", &tag_ver)], &workspace, )?; run_command( "git", [ "tag", "-f", "-s", &tag_ver, "-m", &format!("Version {}", version), ], &workspace, )?; Ok(()) }