Skip to main content

cargo_3ds/
lib.rs

1pub mod command;
2mod graph;
3
4use std::ffi::OsStr;
5use std::io::{BufRead, BufReader};
6use std::path::PathBuf;
7use std::process::{Command, ExitStatus, Stdio};
8use std::{env, fmt, io, process};
9
10use camino::{Utf8Path, Utf8PathBuf};
11use cargo_metadata::{Artifact, Message, Package, TargetKind::*};
12use rustc_version::Channel;
13use semver::Version;
14use serde::Deserialize;
15use tee::TeeReader;
16
17use crate::command::{CargoCmd, Input, Run, Test};
18use crate::graph::UnitGraph;
19
20/// Build a command using [`make_cargo_build_command`] and execute it,
21/// parsing and returning the messages from the spawned process.
22///
23/// For commands that produce an executable output, this function will build the
24/// `.elf` binary that can be used to create other 3ds files.
25pub fn run_cargo(input: &Input, message_format: Option<String>) -> (ExitStatus, Vec<Message>) {
26    let mut command = make_cargo_command(input, &message_format);
27
28    // The unit graph is needed only when compiling a program.
29    if input.cmd.should_compile() {
30        let libctru = if should_use_ctru_debuginfo(&command, input.verbose) {
31            "ctrud"
32        } else {
33            "ctru"
34        };
35
36        let rustflags = command
37            .get_envs()
38            .find(|(var, _)| var == &OsStr::new("RUSTFLAGS"))
39            .and_then(|(_, flags)| flags)
40            .unwrap_or_default()
41            .to_string_lossy();
42
43        let rustflags = format!("{rustflags} -l{libctru}");
44
45        command.env("RUSTFLAGS", rustflags);
46    }
47
48    if input.verbose {
49        print_command(&command);
50    }
51
52    let mut process = command.spawn().unwrap();
53    let command_stdout = process.stdout.take().unwrap();
54
55    let mut tee_reader;
56    let mut stdout_reader;
57
58    let buf_reader: &mut dyn BufRead = match (message_format, &input.cmd) {
59        // The user presumably cares about the message format if set, so we should
60        // copy stuff to stdout like they expect. We can still extract the executable
61        // information out of it that we need for 3dsxtool etc.
62        (Some(_), _) |
63        // Rustdoc unfortunately prints to stdout for compile errors, so
64        // we also use a tee when building doc tests too.
65        // Possibly related: https://github.com/rust-lang/rust/issues/75135
66        (None, CargoCmd::Test(Test { doc: true, .. })) => {
67            tee_reader = BufReader::new(TeeReader::new(command_stdout, io::stdout()));
68            &mut tee_reader
69        }
70        _ => {
71            stdout_reader = BufReader::new(command_stdout);
72            &mut stdout_reader
73        }
74    };
75
76    let messages = Message::parse_stream(buf_reader)
77        .collect::<io::Result<_>>()
78        .unwrap();
79
80    (process.wait().unwrap(), messages)
81}
82
83/// Ensure that we use the same `-lctru[d]` flag that `ctru-sys` is using in its build.
84fn should_use_ctru_debuginfo(cargo_cmd: &Command, verbose: bool) -> bool {
85    match UnitGraph::from_cargo(cargo_cmd, verbose) {
86        Ok(unit_graph) => {
87            let Some(unit) = unit_graph
88                .units
89                .iter()
90                .find(|unit| unit.target.name == "ctru_sys")
91            else {
92                eprintln!(
93                    "Warning: unable to check if `ctru` debuginfo should be linked: `ctru-sys` not found"
94                );
95                return false;
96            };
97
98            let debuginfo = unit.profile.debuginfo.unwrap_or(0);
99            debuginfo > 0
100        }
101        Err(err) => {
102            eprintln!("Warning: unable to check if `ctru` debuginfo should be linked: {err}");
103            false
104        }
105    }
106}
107
108/// Create a cargo command based on the context.
109///
110/// For "build" commands (which compile code, such as `cargo 3ds build` or `cargo 3ds clippy`),
111/// if there is no pre-built std detected in the sysroot, `build-std` will be used instead.
112pub(crate) fn make_cargo_command(input: &Input, message_format: &Option<String>) -> Command {
113    let devkitpro =
114        env::var("DEVKITPRO").expect("DEVKITPRO is not defined as an environment variable");
115    // TODO: should we actually prepend the user's RUSTFLAGS for linking order? not sure
116    let rustflags =
117        env::var("RUSTFLAGS").unwrap_or_default() + &format!(" -L{devkitpro}/libctru/lib");
118
119    let cargo_cmd = &input.cmd;
120
121    let mut command = cargo(&input.config);
122    command
123        .arg(cargo_cmd.subcommand_name())
124        .env("RUSTFLAGS", rustflags);
125
126    // Any command that needs to compile code will run under this environment.
127    // Even `clippy` and `check` need this kind of context, so we'll just assume any other `Passthrough` command uses it too.
128    if cargo_cmd.should_compile() {
129        command
130            .arg("--target")
131            .arg("armv6k-nintendo-3ds")
132            .arg("--message-format")
133            .arg(
134                message_format
135                    .as_deref()
136                    .unwrap_or(CargoCmd::DEFAULT_MESSAGE_FORMAT),
137            );
138
139        let sysroot = find_sysroot();
140        if !sysroot.join("lib/rustlib/armv6k-nintendo-3ds").exists() {
141            // Under most circumstances, the user will just use build-std for convenience.
142            // As such, we warn about the use of build-std only if really asked for.
143            if input.verbose {
144                eprintln!("No pre-build std found, using build-std");
145            }
146
147            // Always building the test crate is not ideal, but we don't know if the
148            // crate being built uses #![feature(test)], so we build it just in case.
149            command.arg("-Z").arg("build-std=std,test");
150        }
151    }
152
153    if let CargoCmd::Test(test) = cargo_cmd {
154        // RUSTDOCFLAGS is simply ignored if --doc wasn't passed, so we always set it.
155        let rustdoc_flags = std::env::var("RUSTDOCFLAGS").unwrap_or_default() + test.rustdocflags();
156        command.env("RUSTDOCFLAGS", rustdoc_flags);
157    }
158
159    command.args(cargo_cmd.cargo_args());
160
161    if let CargoCmd::Run(run) | CargoCmd::Test(Test { run_args: run, .. }) = &cargo_cmd {
162        if run.use_custom_runner() {
163            command
164                .arg("--")
165                .args(run.build_args.passthrough.exe_args());
166        }
167    }
168
169    command
170        .stdout(Stdio::piped())
171        .stdin(Stdio::inherit())
172        .stderr(Stdio::inherit());
173
174    command
175}
176
177/// Build a `cargo` command with the given `--config` flags.
178fn cargo(config: &[String]) -> Command {
179    let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
180    let mut cmd = Command::new(cargo);
181    cmd.args(config.iter().map(|cfg| format!("--config={cfg}")));
182    cmd
183}
184
185fn print_command(command: &Command) {
186    let mut cmd_str = vec![command.get_program().to_string_lossy().to_string()];
187    cmd_str.extend(command.get_args().map(|s| s.to_string_lossy().to_string()));
188
189    eprintln!("Running command:");
190    for (k, v) in command.get_envs() {
191        let v = v.map(|v| v.to_string_lossy().to_string());
192        eprintln!(
193            "   {}={} \\",
194            k.to_string_lossy(),
195            v.map_or_else(String::new, |s| shlex::try_quote(&s).unwrap().to_string())
196        );
197    }
198    eprintln!(
199        "   {}\n",
200        shlex::try_join(cmd_str.iter().map(String::as_str)).unwrap()
201    );
202}
203
204/// Finds the sysroot path of the current toolchain
205pub(crate) fn find_sysroot() -> PathBuf {
206    let sysroot = env::var("SYSROOT").ok().unwrap_or_else(|| {
207        let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
208
209        let output = Command::new(&rustc)
210            .arg("--print")
211            .arg("sysroot")
212            .output()
213            .unwrap_or_else(|_| panic!("Failed to run `{rustc} -- print sysroot`"));
214        String::from_utf8(output.stdout).expect("Failed to parse sysroot path into a UTF-8 string")
215    });
216
217    PathBuf::from(sysroot.trim())
218}
219
220/// Checks the current rust version and channel.
221/// Exits if the minimum requirement is not met.
222pub fn check_rust_version(input: &Input) {
223    let rustc_version = rustc_version::version_meta().unwrap();
224
225    // If the channel isn't nightly, we can't make use of the required unstable tools.
226    // However, `cargo 3ds new` doesn't have these requirements.
227    if rustc_version.channel > Channel::Nightly && input.cmd.should_compile() {
228        eprintln!("building with cargo-3ds requires a nightly rustc version.");
229        eprintln!(
230            "Please run `rustup override set nightly` to use nightly in the \
231            current directory, or use `cargo +nightly 3ds` to use it for a \
232            single invocation."
233        );
234        process::exit(1);
235    }
236
237    let old_version = MINIMUM_RUSTC_VERSION
238        > Version {
239            // Remove `-nightly` pre-release tag for comparison.
240            pre: semver::Prerelease::EMPTY,
241            ..rustc_version.semver.clone()
242        };
243
244    let old_commit = match rustc_version.commit_date {
245        None => false,
246        Some(date) => {
247            MINIMUM_COMMIT_DATE
248                > CommitDate::parse(&date).expect("could not parse `rustc --version` commit date")
249        }
250    };
251
252    if old_version || old_commit {
253        eprintln!("cargo-3ds requires rustc nightly version >= {MINIMUM_COMMIT_DATE}");
254        eprintln!("Please run `rustup update nightly` to upgrade your nightly version");
255
256        process::exit(1);
257    }
258}
259
260/// Parses messages returned by "build" cargo commands (such as `cargo 3ds build` or `cargo 3ds run`).
261/// The returned [`CTRConfig`] is then used for further building in and execution
262/// in [`CTRConfig::build_smdh`], [`build_3dsx`], and [`link`].
263pub(crate) fn get_artifact_config(package: Package, artifact: Artifact) -> CTRConfig {
264    // For now, assume a single "kind" per artifact. It seems to be the case
265    // when a single executable is built anyway but maybe not in all cases.
266    let name = match artifact.target.kind[0] {
267        Bin | Lib | RLib | DyLib if artifact.profile.test => {
268            format!("{} tests", artifact.target.name)
269        }
270        Example => {
271            format!("{} - {} example", artifact.target.name, package.name)
272        }
273        _ => artifact.target.name,
274    };
275
276    // TODO(#62): need to break down by target kind and name, e.g.
277    // [package.metadata.cargo-3ds.example.hello-world]
278    // Probably fall back to top level as well.
279    let config = package
280        .metadata
281        .get("cargo-3ds")
282        .and_then(|c| CTRConfig::deserialize(c).ok())
283        .unwrap_or_default();
284
285    CTRConfig {
286        name,
287        authors: config.authors.or(Some(package.authors)),
288        description: config.description.or(package.description),
289        manifest_dir: package.manifest_path.parent().unwrap().into(),
290        target_path: artifact.executable.unwrap(),
291        ..config
292    }
293}
294
295/// Builds the 3dsx using `3dsxtool`.
296/// This will fail if `3dsxtool` is not within the running directory or in a directory found in $PATH
297pub(crate) fn build_3dsx(config: &CTRConfig, verbose: bool) {
298    let mut command = Command::new("3dsxtool");
299    command
300        .arg(&config.target_path)
301        .arg(config.path_3dsx())
302        .arg(format!("--smdh={}", config.path_smdh()));
303
304    let romfs = config.romfs_dir();
305    if romfs.is_dir() {
306        eprintln!("Adding RomFS from {romfs}");
307        command.arg(format!("--romfs={romfs}"));
308    } else if config.romfs_dir.is_some() {
309        eprintln!("Could not find configured RomFS dir: {romfs}");
310        process::exit(1);
311    }
312
313    if verbose {
314        print_command(&command);
315    }
316
317    let mut process = command
318        .stdin(Stdio::inherit())
319        .stdout(Stdio::inherit())
320        .stderr(Stdio::inherit())
321        .spawn()
322        .expect("3dsxtool command failed, most likely due to '3dsxtool' not being in $PATH");
323
324    let status = process.wait().unwrap();
325
326    if !status.success() {
327        process::exit(status.code().unwrap_or(1));
328    }
329}
330
331/// Link the generated 3dsx to a 3ds to execute and test using `3dslink`.
332/// This will fail if `3dslink` is not within the running directory or in a directory found in $PATH
333pub(crate) fn link(config: &CTRConfig, run_args: &Run, verbose: bool) {
334    let mut command = Command::new("3dslink");
335    command
336        .arg(config.path_3dsx())
337        .args(run_args.get_3dslink_args())
338        .stdin(Stdio::inherit())
339        .stdout(Stdio::inherit())
340        .stderr(Stdio::inherit());
341
342    if verbose {
343        print_command(&command);
344    }
345
346    let status = command.spawn().unwrap().wait().unwrap();
347
348    if !status.success() {
349        process::exit(status.code().unwrap_or(1));
350    }
351}
352
353#[derive(Default, Debug, Deserialize, PartialEq, Eq)]
354pub struct CTRConfig {
355    /// The authors of the application, which will be joined by `", "` to form
356    /// the `Publisher` field in the SMDH format. If not specified, a single author
357    /// of "Unspecified Author" will be used.
358    authors: Option<Vec<String>>,
359
360    /// A description of the application, also called `Long Description` in the
361    /// SMDH format. The following values will be used in order of precedence:
362    /// - `cargo-3ds` metadata field
363    /// - `package.description` in Cargo.toml
364    /// - "Homebrew Application"
365    description: Option<String>,
366
367    /// The path to the app icon, defaulting to `$CARGO_MANIFEST_DIR/icon.png`
368    /// if it exists. If not specified, the devkitPro default icon is used.
369    icon_path: Option<Utf8PathBuf>,
370
371    /// The path to the romfs directory, defaulting to `$CARGO_MANIFEST_DIR/romfs`
372    /// if it exists, or unused otherwise. If a path is specified but does not
373    /// exist, an error occurs.
374    #[serde(alias = "romfs-dir")]
375    romfs_dir: Option<Utf8PathBuf>,
376
377    // Remaining fields come from cargo metadata / build artifact output and
378    // cannot be customized by users in `package.metadata.cargo-3ds`. I suppose
379    // in theory we could allow name to be customizable if we wanted...
380    #[serde(skip)]
381    name: String,
382    #[serde(skip)]
383    target_path: Utf8PathBuf,
384    #[serde(skip)]
385    manifest_dir: Utf8PathBuf,
386}
387
388impl CTRConfig {
389    /// Get the path to the output `.3dsx` file.
390    pub(crate) fn path_3dsx(&self) -> Utf8PathBuf {
391        self.target_path.with_extension("3dsx")
392    }
393
394    /// Get the path to the output `.smdh` file.
395    pub(crate) fn path_smdh(&self) -> Utf8PathBuf {
396        self.target_path.with_extension("smdh")
397    }
398
399    /// Get the absolute path to the romfs directory, defaulting to `romfs` if not specified.
400    pub(crate) fn romfs_dir(&self) -> Utf8PathBuf {
401        self.manifest_dir
402            .join(self.romfs_dir.as_deref().unwrap_or(Utf8Path::new("romfs")))
403    }
404
405    // as standard with the devkitPRO toolchain
406    const DEFAULT_AUTHOR: &'static str = "Unspecified Author";
407    const DEFAULT_DESCRIPTION: &'static str = "Homebrew Application";
408
409    /// Builds the smdh using `smdhtool`.
410    /// This will fail if `smdhtool` is not within the running directory or in a directory found in $PATH
411    pub(crate) fn build_smdh(&self, verbose: bool) {
412        let description = self
413            .description
414            .as_deref()
415            .unwrap_or(Self::DEFAULT_DESCRIPTION);
416
417        let publisher = if let Some(authors) = self.authors.as_ref() {
418            authors.join(", ")
419        } else {
420            Self::DEFAULT_AUTHOR.to_string()
421        };
422
423        let icon_path = self.icon_path().unwrap_or_else(|err_path| {
424            eprintln!("Icon at {err_path} does not exist");
425            process::exit(1);
426        });
427
428        let mut command = Command::new("smdhtool");
429        command
430            .arg("--create")
431            .arg(&self.name)
432            .arg(description)
433            .arg(publisher)
434            .arg(icon_path)
435            .arg(self.path_smdh())
436            .stdin(Stdio::inherit())
437            .stdout(Stdio::inherit())
438            .stderr(Stdio::inherit());
439
440        if verbose {
441            print_command(&command);
442        }
443
444        let mut process = command
445            .spawn()
446            .expect("smdhtool command failed, most likely due to 'smdhtool' not being in $PATH");
447
448        let status = process.wait().unwrap();
449
450        if !status.success() {
451            process::exit(status.code().unwrap_or(1));
452        }
453    }
454
455    /// Get the path to the icon to be used for the SMDH output.
456    ///
457    /// # Errors
458    ///
459    /// Returns an error if the specified (or fallback) path does not exist.
460    /// The contained path is the path we tried to use.
461    fn icon_path(&self) -> Result<Utf8PathBuf, Utf8PathBuf> {
462        let path = if let Some(path) = &self.icon_path {
463            self.manifest_dir.join(path)
464        } else {
465            let path = self.manifest_dir.join("icon.png");
466            if path.exists() {
467                return Ok(path);
468            }
469
470            Utf8PathBuf::from(env::var("DEVKITPRO").unwrap())
471                .join("libctru")
472                .join("default_icon.png")
473        };
474
475        if path.exists() { Ok(path) } else { Err(path) }
476    }
477}
478
479#[derive(Ord, PartialOrd, PartialEq, Eq, Debug)]
480pub struct CommitDate {
481    year: i32,
482    month: i32,
483    day: i32,
484}
485
486impl CommitDate {
487    fn parse(date: &str) -> Option<Self> {
488        let mut iter = date.split('-');
489
490        let year = iter.next()?.parse().ok()?;
491        let month = iter.next()?.parse().ok()?;
492        let day = iter.next()?.parse().ok()?;
493
494        Some(Self { year, month, day })
495    }
496}
497
498impl fmt::Display for CommitDate {
499    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
500        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
501    }
502}
503
504const MINIMUM_COMMIT_DATE: CommitDate = CommitDate {
505    year: 2023,
506    month: 5,
507    day: 31,
508};
509const MINIMUM_RUSTC_VERSION: Version = Version::new(1, 70, 0);