cargo_rbrew/
lib.rs

1// TODOS:
2//
3// - make `--help` output details of all possible platforms.
4// - make `--help` output details of all possible output types.
5
6#![feature(exitcode_exit_method)]
7
8use argp::{FromArgValue, FromArgs};
9use std::{
10    ffi::OsStr,
11    fmt::Display,
12    path::{Path, PathBuf},
13    process::{Command, ExitCode},
14};
15
16mod tools;
17
18fn graceful_error_exit(msg: impl Display) -> ! {
19    eprintln!("Exit failure.\n{msg}");
20    ExitCode::FAILURE.exit_process()
21}
22
23mod fields {
24    use super::*;
25
26    #[derive(Clone, Copy)]
27    pub enum Platform {
28        Gamecube,
29    }
30
31    impl Platform {
32        pub fn target_json_name(self) -> &'static str {
33            match self {
34                Platform::Gamecube => "gamecube.json",
35            }
36        }
37
38        pub fn config_toml_name(self) -> &'static str {
39            match self {
40                Platform::Gamecube => "gamecube.toml",
41            }
42        }
43    }
44
45    impl FromArgValue for Platform {
46        fn from_arg_value(value: &std::ffi::OsStr) -> Result<Self, String> {
47            let str = value.to_str().ok_or("invalid UTF-8 string".to_string())?;
48            Ok(match str {
49                "gamecube" | "gc" => Self::Gamecube,
50                _ => return Err("expected a valid platform.".to_string()),
51            })
52        }
53    }
54
55    #[derive(Default, Clone, Copy)]
56    pub enum OutputType {
57        #[default]
58        Elf,
59        Dol,
60    }
61
62    impl FromArgValue for OutputType {
63        fn from_arg_value(value: &std::ffi::OsStr) -> Result<Self, String> {
64            let str = value.to_str().ok_or("invalid UTF-8 string".to_string())?;
65            Ok(match str {
66                "elf" => Self::Elf,
67                "dol" => Self::Dol,
68                _ => return Err("expected a valid output type.".to_string()),
69            })
70        }
71    }
72
73    impl OutputType {
74        pub fn supports_platform(self, platform: Platform) -> bool {
75            #[allow(unreachable_patterns)]
76            #[allow(clippy::match_like_matches_macro)]
77            match (self, platform) {
78                (Self::Elf, _) | (Self::Dol, Platform::Gamecube) => true,
79                _ => false,
80            }
81        }
82
83        pub fn extension_name(self) -> &'static str {
84            match self {
85                OutputType::Elf => "",
86                OutputType::Dol => ".dol",
87            }
88        }
89    }
90}
91
92/// The rbrew build subcommand.
93#[derive(FromArgs)]
94#[argp(subcommand, name = "build")]
95struct RbrewCliSubBuild {
96    /// The platform to build for.
97    /// See `--help` for more details.
98    #[argp(option)]
99    platform: fields::Platform,
100    /// Output file type.
101    #[argp(option, default = "Default::default()")]
102    output_type: fields::OutputType,
103    /// Build all packages in the workspace.
104    #[argp(switch)]
105    workspace: bool,
106    /// Builds the specific package in the workspace.
107    #[argp(option)]
108    package: Option<String>,
109    /// Output directory.
110    #[argp(option)]
111    output_directory: Option<PathBuf>,
112    /// Custom cargo flags.
113    #[argp(option)]
114    custom_options: Vec<String>,
115}
116
117/// The rbrew tools subommand.
118#[derive(FromArgs)]
119#[argp(subcommand, name = "tools")]
120struct RbrewCliSubTools {}
121
122#[derive(FromArgs)]
123#[argp(subcommand)]
124enum RbrewCliSub {
125    Build(RbrewCliSubBuild),
126    Tools(RbrewCliSubTools),
127}
128
129#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
130enum Verbosity {
131    Quiet,
132    Normal,
133    Verbose,
134}
135
136impl Verbosity {
137    fn should_output(self, level: Self) -> bool {
138        self >= level
139    }
140}
141
142impl Default for Verbosity {
143    fn default() -> Self {
144        Self::Normal
145    }
146}
147
148impl FromArgValue for Verbosity {
149    fn from_arg_value(value: &std::ffi::OsStr) -> Result<Self, String> {
150        Ok(match value.to_str() {
151            Some("quiet") => Self::Quiet,
152            Some("normal") => Self::Normal,
153            Some("verbose") => Self::Verbose,
154            _ => return Err("expected 'quiet', 'normal' or 'verbose'.".to_string()),
155        })
156    }
157}
158
159/// The rbrew command.
160#[derive(FromArgs)]
161pub struct RbrewCli {
162    /// Determines the verbosity of the stdout output.
163    #[argp(option, default = "Default::default()")]
164    verbosity: Verbosity,
165
166    #[argp(subcommand)]
167    subcommand: RbrewCliSub,
168}
169
170mod util {
171    use super::*;
172
173    pub fn cargo() -> Command {
174        Command::new("cargo")
175    }
176
177    fn try_path(path: PathBuf) -> Result<PathBuf, std::io::Error> {
178        if path.exists() {
179            Ok(path)
180        } else {
181            Err(std::io::Error::new(
182                std::io::ErrorKind::NotFound,
183                format!("unable to find path '{path:?}'"),
184            ))
185        }
186    }
187
188    pub fn rbrew_target_file(name: &str) -> Result<PathBuf, std::io::Error> {
189        try_path(PathBuf::from(format!(
190            "{}/{name}",
191            include_str!(concat!(env!("OUT_DIR"), "/target_path.inc"))
192        )))
193    }
194
195    pub fn rbrew_config_file(name: &str) -> Result<PathBuf, std::io::Error> {
196        try_path(PathBuf::from(format!(
197            "{}/{name}",
198            include_str!(concat!(env!("OUT_DIR"), "/config_path.inc"))
199        )))
200    }
201}
202
203pub fn run(cli: RbrewCli) {
204    match cli.subcommand {
205        RbrewCliSub::Build(args) => build(args, cli.verbosity),
206        RbrewCliSub::Tools(args) => tools(args, cli.verbosity),
207    }
208}
209
210fn build(args: RbrewCliSubBuild, verbosity: Verbosity) {
211    if !args.output_type.supports_platform(args.platform) {
212        graceful_error_exit("output type does not support platform. See `--help`.")
213    }
214
215    let mut cmd = util::cargo();
216    cmd.arg("build");
217    if let Some(package) = &args.package {
218        cmd.arg("--package").arg(package);
219    }
220    if args.workspace {
221        cmd.arg("--workspace");
222    }
223
224    let target_json_ident = args.platform.target_json_name();
225    let target_json = match util::rbrew_target_file(target_json_ident) {
226        Ok(ok) => ok,
227        Err(err) => graceful_error_exit(format!(
228            "failed to find the target json file for the platform: {err}"
229        )),
230    };
231
232    let target_config_ident = args.platform.config_toml_name();
233    let target_config = match util::rbrew_config_file(target_config_ident) {
234        Ok(ok) => ok,
235        Err(err) => graceful_error_exit(format!(
236            "failed to find the config toml file for the platform: {err}"
237        )),
238    };
239
240    // cmd.arg(format!("--target={}", target_json.display()));
241    cmd.arg(format!("--config={}", target_config.display()));
242
243    for option in &args.custom_options {
244        cmd.arg(option);
245    }
246
247    let mut status_cmd = Command::new(cmd.get_program());
248    status_cmd.args(cmd.get_args());
249    status_cmd.envs(cmd.get_envs().map(|env| (env.0, env.1.unwrap_or_default())));
250
251    let mut output_cmd = cmd;
252
253    match verbosity {
254        Verbosity::Quiet => {
255            status_cmd.arg("--quiet");
256        }
257        Verbosity::Normal => {}
258        Verbosity::Verbose => {
259            status_cmd.arg("--verbose");
260        }
261    };
262    let status = status_cmd
263        .status()
264        .expect("failed to execute cargo command");
265    if !status.success() {
266        graceful_error_exit("something went wrong when running cargo.")
267    }
268
269    let output = output_cmd
270        .arg("--message-format=json")
271        .arg("--quiet")
272        .output()
273        .unwrap();
274    if !output.status.success() {
275        panic!("should never be possible if we succeeded before");
276    }
277
278    let utf8 = String::from_utf8(output.stdout).expect("expected valid UTF-8");
279    let mut jsons = vec![];
280    for line in utf8.lines() {
281        jsons.push(json::parse(line).expect("expected valid json"))
282    }
283
284    let mut output_executable = vec![];
285    for json in jsons {
286        match json {
287            json::JsonValue::Object(object) => {
288                if let Some(executable) = object.get("executable") {
289                    if let Some(str) = executable.as_str() {
290                        output_executable.push(str.to_string())
291                    }
292                }
293            }
294            _ => panic!("expected json object"),
295        }
296    }
297
298    for (gen, input) in output_executable.into_iter().enumerate() {
299        let input = Path::new(&input);
300        let output_dir = args
301            .output_directory
302            .clone()
303            .unwrap_or(input.parent().map(Path::to_path_buf).unwrap_or_default());
304        let output_gennerated_name = format!("output{gen}");
305        let output_name = input
306            .file_stem()
307            .unwrap_or(OsStr::new(&output_gennerated_name));
308
309        let mut output = output_dir.join(output_name);
310        output.set_extension(args.output_type.extension_name());
311
312        if verbosity.should_output(Verbosity::Normal) {
313            println!("output file: {}", output.display());
314        }
315
316        match args.output_type {
317            fields::OutputType::Elf => {
318                std::fs::copy(input, output).unwrap();
319            }
320            fields::OutputType::Dol => {
321                tools::elf2dol(input, output).unwrap();
322            }
323        }
324    }
325}
326
327fn tools(_args: RbrewCliSubTools, _verbosity: Verbosity) {}