Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build multiple 3DSX files (one per cargo artifact) #60

Merged
merged 8 commits into from
Jun 16, 2024
81 changes: 58 additions & 23 deletions src/command.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use std::fs;
use std::io::Read;
use std::process;
use std::process::Stdio;
ian-h-chamberlain marked this conversation as resolved.
Show resolved Hide resolved
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")]
Expand Down Expand Up @@ -295,23 +296,64 @@
///
/// - `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_callback(&self, messages: &[Message], metadata: &Metadata) {
let mut configs = Vec::new();

Some(get_metadata(messages))
} else {
None
};
// Process the metadata only for commands that have it/use it
if self.should_build_3dsx() {
configs = self.run_build_callbacks(messages, metadata);
}

// Run callback only for commands that use it
// For run + test, we can only run one of the targets. Error if more or less
// than one executable was built, otherwise run the callback for the first target.
match self {
Self::Build(cmd) => cmd.callback(&config),
Self::Run(cmd) => cmd.callback(&config),
Self::Test(cmd) => cmd.callback(&config),
Self::Run(cmd) if configs.len() == 1 => cmd.callback(&configs.into_iter().next()),
Self::Test(cmd) if configs.len() == 1 => cmd.callback(&configs.into_iter().next()),
Self::Run(_) | Self::Test(_) => {
let names: Vec<_> = configs.into_iter().map(|c| c.name).collect();
eprintln!("Error: expected exactly one executable to run, got {names:?}");
process::exit(1);
}
// New is a special case where we always want to run its callback
Self::New(cmd) => cmd.callback(),
_ => (),
_ => {}
}
}

/// Generate a .3dsx for every executable artifact within the workspace that
/// was built by the cargo command.
fn run_build_callbacks(&self, messages: &[Message], metadata: &Metadata) -> Vec<CTRConfig> {
let mut configs = Vec::new();
ian-h-chamberlain marked this conversation as resolved.
Show resolved Hide resolved

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 = Some(get_artifact_config(package.clone(), artifact.clone()));

self.run_artifact_callback(&config);

configs.push(config.unwrap());

Check warning on line 344 in src/command.rs

View workflow job for this annotation

GitHub Actions / lint (stable)

used `unwrap()` on `Some` value
ian-h-chamberlain marked this conversation as resolved.
Show resolved Hide resolved
}

configs
}

// TODO: can we just swap all signatures to &CTRConfig? Seems more sensible now
fn run_artifact_callback(&self, config: &Option<CTRConfig>) {
match self {
Self::Build(cmd) => cmd.callback(config),
Self::Run(cmd) => cmd.build_args.callback(config),
Self::Test(cmd) => cmd.run_args.build_args.callback(config),
_ => {}
}
}
}
Expand Down Expand Up @@ -403,9 +445,6 @@
///
/// This callback handles launching the application via `3dslink`.
fn callback(&self, config: &Option<CTRConfig>) {
// Run the normal "build" callback
self.build_args.callback(config);

if !self.use_custom_runner() {
if let Some(cfg) = config {
eprintln!("Running 3dslink");
Expand Down Expand Up @@ -462,11 +501,7 @@
///
/// This callback handles launching the application via `3dslink`.
fn callback(&self, config: &Option<CTRConfig>) {
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
if !self.no_run {
self.run_args.callback(config);
}
}
Expand Down
151 changes: 85 additions & 66 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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)
Expand All @@ -299,30 +266,25 @@ pub fn get_metadata(messages: &[Message]) -> CTRConfig {
_ => artifact.target.name,
};

let romfs_dir = get_romfs_dir(&package);
// TODO: 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())
Meziu marked this conversation as resolved.
Show resolved Hide resolved
.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<Utf8PathBuf> {
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) {
Expand Down Expand Up @@ -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<Vec<String>>,

/// 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<String>,

/// 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<Utf8PathBuf>,

/// 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<Utf8PathBuf>,

// 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<String>,
description: String,
icon_path: Utf8PathBuf,
#[serde(skip)]
target_path: Utf8PathBuf,
#[serde(skip)]
manifest_dir: Utf8PathBuf,
romfs_dir: Option<Utf8PathBuf>,
}

impl CTRConfig {
Expand All @@ -409,22 +395,31 @@ 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 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(self.icon_path())
.arg(self.path_smdh())
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
Expand All @@ -444,6 +439,30 @@ impl CTRConfig {
process::exit(status.code().unwrap_or(1));
}
}

/// Possible cases:
/// - icon path specified (exit with error if doesn't exist)
/// - icon path unspecified, icon.png exists
/// - icon path unspecified, icon.png does not exist
fn icon_path(&self) -> Utf8PathBuf {
Meziu marked this conversation as resolved.
Show resolved Hide resolved
let abs_path = self.manifest_dir.join(
self.icon_path
.as_deref()
.unwrap_or(Utf8Path::new("icon.png")),
);

if abs_path.is_file() {
abs_path
} else if self.icon_path.is_some() {
eprintln!("Specified icon path does not exist: {abs_path}");
process::exit(1);
} else {
// We assume this default icon will always exist as part of the toolchain
Utf8PathBuf::from(env::var("DEVKITPRO").unwrap())
.join("libctru")
.join("default_icon.png")
}
}
}

#[derive(Ord, PartialOrd, PartialEq, Eq, Debug)]
Expand Down
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ fn main() {
}
};

let metadata = cargo_metadata::MetadataCommand::new()
.no_deps()
.exec()
.unwrap();

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_callback(&messages, &metadata);
}
Loading