diff --git a/src/command.rs b/src/command.rs index 4b0dab7..35f2c32 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,12 +1,12 @@ use std::fs; use std::io::Read; -use std::process::Stdio; +use std::process::{self, Stdio}; use std::sync::OnceLock; -use cargo_metadata::Message; +use cargo_metadata::{Message, Metadata}; use clap::{Args, Parser, Subcommand}; -use crate::{build_3dsx, cargo, get_metadata, link, print_command, CTRConfig}; +use crate::{build_3dsx, cargo, get_artifact_config, link, print_command, CTRConfig}; #[derive(Parser, Debug)] #[command(name = "cargo", bin_name = "cargo")] @@ -83,6 +83,12 @@ pub struct RemainingArgs { args: Vec, } +#[allow(unused_variables)] +trait Callbacks { + fn build_callback(&self, config: &CTRConfig) {} + fn run_callback(&self, config: &CTRConfig) {} +} + #[derive(Args, Debug)] pub struct Build { #[arg(from_global)] @@ -295,23 +301,93 @@ impl CargoCmd { /// /// - `cargo 3ds build` and other "build" commands will use their callbacks to build the final `.3dsx` file and link it. /// - `cargo 3ds new` and other generic commands will use their callbacks to make 3ds-specific changes to the environment. - pub fn run_callback(&self, messages: &[Message]) { - // Process the metadata only for commands that have it/use it - let config = if self.should_build_3dsx() { - eprintln!("Getting metadata"); + pub fn run_callbacks(&self, messages: &[Message], metadata: Option<&Metadata>) { + let configs = metadata + .map(|metadata| self.build_callbacks(messages, metadata)) + .unwrap_or_default(); + + let config = match self { + // If we produced one executable, we will attempt to run that one + _ if configs.len() == 1 => configs.into_iter().next().unwrap(), + + // --no-run may produce any number of executables, and we skip the callback + Self::Test(Test { no_run: true, .. }) => return, + + // If using custom runners, they may be able to handle multiple executables, + // and we also want to skip our own callback. `cargo run` also has its own + // logic to disallow multiple executables. + Self::Test(Test { run_args: run, .. }) | Self::Run(run) if run.use_custom_runner() => { + return + } - Some(get_metadata(messages)) - } else { - None + // Config is ignored by the New callback, using default is fine. + Self::New(_) => CTRConfig::default(), + + // Otherwise (configs.len() != 1) print an error and exit + Self::Test(_) | Self::Run(_) => { + let paths: Vec<_> = configs.into_iter().map(|c| c.path_3dsx()).collect(); + let names: Vec<_> = paths.iter().filter_map(|p| p.file_name()).collect(); + eprintln!( + "Error: expected exactly one (1) executable to run, got {}: {names:?}", + paths.len(), + ); + process::exit(1); + } + + _ => return, }; - // Run callback only for commands that use it + self.run_callback(&config); + } + + /// Generate a .3dsx for every executable artifact within the workspace that + /// was built by the cargo command. + fn build_callbacks(&self, messages: &[Message], metadata: &Metadata) -> Vec { + let max_artifact_count = metadata.packages.iter().map(|pkg| pkg.targets.len()).sum(); + let mut configs = Vec::with_capacity(max_artifact_count); + + for message in messages { + let Message::CompilerArtifact(artifact) = message else { + continue; + }; + + if artifact.executable.is_none() + || !metadata.workspace_members.contains(&artifact.package_id) + { + continue; + } + + let package = &metadata[&artifact.package_id]; + let config = get_artifact_config(package.clone(), artifact.clone()); + + self.build_callback(&config); + + configs.push(config); + } + + configs + } + + fn inner_callback(&self) -> Option<&dyn Callbacks> { match self { - Self::Build(cmd) => cmd.callback(&config), - Self::Run(cmd) => cmd.callback(&config), - Self::Test(cmd) => cmd.callback(&config), - Self::New(cmd) => cmd.callback(), - _ => (), + Self::Build(cmd) => Some(cmd), + Self::Run(cmd) => Some(cmd), + Self::Test(cmd) => Some(cmd), + _ => None, + } + } +} + +impl Callbacks for CargoCmd { + fn build_callback(&self, config: &CTRConfig) { + if let Some(cb) = self.inner_callback() { + cb.build_callback(config); + } + } + + fn run_callback(&self, config: &CTRConfig) { + if let Some(cb) = self.inner_callback() { + cb.run_callback(config); } } } @@ -342,18 +418,30 @@ impl RemainingArgs { } } -impl Build { +impl Callbacks for Build { /// Callback for `cargo 3ds build`. /// /// This callback handles building the application as a `.3dsx` file. - fn callback(&self, config: &Option) { - if let Some(config) = config { - eprintln!("Building smdh: {}", config.path_smdh()); - config.build_smdh(self.verbose); + fn build_callback(&self, config: &CTRConfig) { + eprintln!("Building smdh: {}", config.path_smdh()); + config.build_smdh(self.verbose); - eprintln!("Building 3dsx: {}", config.path_3dsx()); - build_3dsx(config, self.verbose); - } + eprintln!("Building 3dsx: {}", config.path_3dsx()); + build_3dsx(config, self.verbose); + } +} + +impl Callbacks for Run { + fn build_callback(&self, config: &CTRConfig) { + self.build_args.build_callback(config); + } + + /// Callback for `cargo 3ds run`. + /// + /// This callback handles launching the application via `3dslink`. + fn run_callback(&self, config: &CTRConfig) { + eprintln!("Running 3dslink"); + link(config, self, self.build_args.verbose); } } @@ -399,21 +487,6 @@ impl Run { args } - /// Callback for `cargo 3ds run`. - /// - /// This callback handles launching the application via `3dslink`. - fn callback(&self, config: &Option) { - // Run the normal "build" callback - self.build_args.callback(config); - - if !self.use_custom_runner() { - if let Some(cfg) = config { - eprintln!("Running 3dslink"); - link(cfg, self, self.build_args.verbose); - } - } - } - /// Returns whether the cargo environment has `target.armv6k-nintendo-3ds.runner` /// configured. This will only be checked once during the lifetime of the program, /// and takes into account the usual ways Cargo looks for its @@ -457,20 +530,22 @@ impl Run { } } -impl Test { +impl Callbacks for Test { + fn build_callback(&self, config: &CTRConfig) { + self.run_args.build_callback(config); + } + /// Callback for `cargo 3ds test`. /// /// This callback handles launching the application via `3dslink`. - fn callback(&self, config: &Option) { - if self.no_run { - // If the tests don't have to run, use the "build" callback - self.run_args.build_args.callback(config); - } else { - // If the tests have to run, use the "run" callback - self.run_args.callback(config); + fn run_callback(&self, config: &CTRConfig) { + if !self.no_run { + self.run_args.run_callback(config); } } +} +impl Test { fn should_run(&self) -> bool { self.run_args.use_custom_runner() && !self.no_run } @@ -540,11 +615,11 @@ fn main() { } "#; -impl New { +impl Callbacks for New { /// Callback for `cargo 3ds new`. /// /// This callback handles the custom environment modifications when creating a new 3DS project. - fn callback(&self) { + fn run_callback(&self, _: &CTRConfig) { // Commmit changes to the project only if is meant to be a binary if self.cargo_args.args.contains(&"--lib".to_string()) { return; diff --git a/src/lib.rs b/src/lib.rs index 778b06d..43d2753 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,9 +8,10 @@ use std::process::{Command, ExitStatus, Stdio}; use std::{env, fmt, io, process}; use camino::{Utf8Path, Utf8PathBuf}; -use cargo_metadata::{Message, MetadataCommand, Package}; +use cargo_metadata::{Artifact, Message, Package}; use rustc_version::Channel; use semver::Version; +use serde::Deserialize; use tee::TeeReader; use crate::command::{CargoCmd, Input, Run, Test}; @@ -251,44 +252,10 @@ pub fn check_rust_version(input: &Input) { /// Parses messages returned by "build" cargo commands (such as `cargo 3ds build` or `cargo 3ds run`). /// The returned [`CTRConfig`] is then used for further building in and execution -/// in [`build_smdh`], [`build_3dsx`], and [`link`]. -pub fn get_metadata(messages: &[Message]) -> CTRConfig { - let metadata = MetadataCommand::new() - .no_deps() - .exec() - .expect("Failed to get cargo metadata"); - - let mut package = None; - let mut artifact = None; - - // Extract the final built executable. We may want to fail in cases where - // multiple executables, or none, were built? - for message in messages.iter().rev() { - if let Message::CompilerArtifact(art) = message { - if art.executable.is_some() { - package = Some(metadata[&art.package_id].clone()); - artifact = Some(art.clone()); - - break; - } - } - } - if package.is_none() || artifact.is_none() { - eprintln!("No executable found from build command output!"); - process::exit(1); - } - - let (package, artifact) = (package.unwrap(), artifact.unwrap()); - - let mut icon_path = Utf8PathBuf::from("./icon.png"); - - if !icon_path.exists() { - icon_path = Utf8PathBuf::from(env::var("DEVKITPRO").unwrap()) - .join("libctru") - .join("default_icon.png"); - } - - // for now assume a single "kind" since we only support one output artifact +/// in [`CTRConfig::build_smdh`], [`build_3dsx`], and [`link`]. +pub fn get_artifact_config(package: Package, artifact: Artifact) -> CTRConfig { + // For now, assume a single "kind" per artifact. It seems to be the case + // when a single executable is built anyway but maybe not in all cases. let name = match artifact.target.kind[0].as_ref() { "bin" | "lib" | "rlib" | "dylib" if artifact.target.test => { format!("{} tests", artifact.target.name) @@ -299,30 +266,25 @@ pub fn get_metadata(messages: &[Message]) -> CTRConfig { _ => artifact.target.name, }; - let romfs_dir = get_romfs_dir(&package); + // TODO(#62): need to break down by target kind and name, e.g. + // [package.metadata.cargo-3ds.example.hello-world] + // Probably fall back to top level as well. + let config = package + .metadata + .get("cargo-3ds") + .and_then(|c| CTRConfig::deserialize(c).ok()) + .unwrap_or_default(); CTRConfig { name, - authors: package.authors, - description: package - .description - .unwrap_or_else(|| String::from("Homebrew Application")), - icon_path, - romfs_dir, + authors: config.authors.or(Some(package.authors)), + description: config.description.or(package.description), manifest_dir: package.manifest_path.parent().unwrap().into(), target_path: artifact.executable.unwrap(), + ..config } } -fn get_romfs_dir(package: &Package) -> Option { - package - .metadata - .get("cargo-3ds")? - .get("romfs_dir")? - .as_str() - .map(Utf8PathBuf::from) -} - /// Builds the 3dsx using `3dsxtool`. /// This will fail if `3dsxtool` is not within the running directory or in a directory found in $PATH pub fn build_3dsx(config: &CTRConfig, verbose: bool) { @@ -381,15 +343,39 @@ pub fn link(config: &CTRConfig, run_args: &Run, verbose: bool) { } } -#[derive(Default, Debug)] +#[derive(Default, Debug, Deserialize, PartialEq, Eq)] pub struct CTRConfig { + /// The authors of the application, which will be joined by `", "` to form + /// the `Publisher` field in the SMDH format. If not specified, a single author + /// of "Unspecified Author" will be used. + authors: Option>, + + /// A description of the application, also called `Long Description` in the + /// SMDH format. The following values will be used in order of precedence: + /// - `cargo-3ds` metadata field + /// - `package.description` in Cargo.toml + /// - "Homebrew Application" + description: Option, + + /// The path to the app icon, defaulting to `$CARGO_MANIFEST_DIR/icon.png` + /// if it exists. If not specified, the devkitPro default icon is used. + icon_path: Option, + + /// The path to the romfs directory, defaulting to `$CARGO_MANIFEST_DIR/romfs` + /// if it exists, or unused otherwise. If a path is specified but does not + /// exist, an error occurs. + #[serde(alias = "romfs-dir")] + romfs_dir: Option, + + // Remaining fields come from cargo metadata / build artifact output and + // cannot be customized by users in `package.metadata.cargo-3ds`. I suppose + // in theory we could allow name to be customizable if we wanted... + #[serde(skip)] name: String, - authors: Vec, - description: String, - icon_path: Utf8PathBuf, + #[serde(skip)] target_path: Utf8PathBuf, + #[serde(skip)] manifest_dir: Utf8PathBuf, - romfs_dir: Option, } impl CTRConfig { @@ -409,22 +395,36 @@ impl CTRConfig { .join(self.romfs_dir.as_deref().unwrap_or(Utf8Path::new("romfs"))) } + // as standard with the devkitPRO toolchain + const DEFAULT_AUTHOR: &'static str = "Unspecified Author"; + const DEFAULT_DESCRIPTION: &'static str = "Homebrew Application"; + /// Builds the smdh using `smdhtool`. /// This will fail if `smdhtool` is not within the running directory or in a directory found in $PATH pub fn build_smdh(&self, verbose: bool) { - let author = if self.authors.is_empty() { - String::from("Unspecified Author") // as standard with the devkitPRO toolchain + let description = self + .description + .as_deref() + .unwrap_or(Self::DEFAULT_DESCRIPTION); + + let publisher = if let Some(authors) = self.authors.as_ref() { + authors.join(", ") } else { - self.authors.join(", ") + Self::DEFAULT_AUTHOR.to_string() }; + let icon_path = self.icon_path().unwrap_or_else(|err_path| { + eprintln!("Icon at {err_path} does not exist"); + process::exit(1); + }); + let mut command = Command::new("smdhtool"); command .arg("--create") .arg(&self.name) - .arg(&self.description) - .arg(author) - .arg(&self.icon_path) + .arg(description) + .arg(publisher) + .arg(icon_path) .arg(self.path_smdh()) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) @@ -444,6 +444,33 @@ impl CTRConfig { process::exit(status.code().unwrap_or(1)); } } + + /// Get the path to the icon to be used for the SMDH output. + /// + /// # Errors + /// + /// Returns an error if the specified (or fallback) path does not exist. + /// The contained path is the path we tried to use. + fn icon_path(&self) -> Result { + let path = if let Some(path) = &self.icon_path { + self.manifest_dir.join(path) + } else { + let path = self.manifest_dir.join("icon.png"); + if path.exists() { + return Ok(path); + } + + Utf8PathBuf::from(env::var("DEVKITPRO").unwrap()) + .join("libctru") + .join("default_icon.png") + }; + + if path.exists() { + Ok(path) + } else { + Err(path) + } + } } #[derive(Ord, PartialOrd, PartialEq, Eq, Debug)] diff --git a/src/main.rs b/src/main.rs index d43bf57..63140ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,11 +18,23 @@ fn main() { } }; + let metadata = if input.cmd.should_build_3dsx() { + match cargo_metadata::MetadataCommand::new().no_deps().exec() { + Ok(metadata) => Some(metadata), + Err(err) => { + eprintln!("Warning: failed to gather cargo metadata for the project: {err}"); + None + } + } + } else { + None + }; + let (status, messages) = run_cargo(&input, message_format); if !status.success() { process::exit(status.code().unwrap_or(1)); } - input.cmd.run_callback(&messages); + input.cmd.run_callbacks(&messages, metadata.as_ref()); }