crust / Part 3 - Build core

Rusty boat: attribution https://www.pexels.com/photo/a-rusty-boat-on-the-seashore-9816335/

In this article we will start filling in our crust-build project, writing the core set of utilities and data structures to support the build operations we’ll be performing and to stub out the skeleton for building each of our target platforms.

This also demonstrates how to create a Rust command line interface application (CLI) which could be a useful thing to know if you wanted to use Rust for authoring general command line tool systems applications.


crust-build application

The job of our crust-build application is to be able to build each target platform we wish to support. This will include tasks such downloading and preparing non Rust dependencies, compiling Rust code and collating the outputs into fully formed working products. We are going to use a third party Rust library named clap to help us with parsing command line arguments and use those command line arguments to determine what to build and whether it should be a debug or release version. The flow will be:

  1. Evaluate the --target argument that is passed in via the command line and find a matching target platform that we want to support - or fail if an invalid target was specified
  2. Evaluate the --variant argument to determine if a debug or release build is desired
  3. Calculate the properties needed by the build and store them in a model which we will refer to as the build context
  4. Kick off the appropriate code path containing the build implementation for the evaluated target platform
  5. Profit!

Manifest

First up we need to update the Cargo manifest. Change crust-build/Cargo.toml to:

[package]
name = "crust-build"
version = "1.0.0"
authors = ["Marcel Braghetto"]
edition = "2021"
rust-version = "1.59.0"

[dependencies]
clap = "2.33.3"

We will start off with the clap dependency but in subsequent articles we’ll add others as needed.

Code structure

Ok, so we are about to start writing some real Rust code - the first thing we will do is create some data structures that model different aspects of our build system.

To keep our code neat and tidy we are going to put some of our source files into modules. A Rust module is a scoped group of source code (often written inside Rust source (.rs) files) which can expose or hide its members to code outside the module.

The module system feels a tiny bit like packages in other languages where a directory containing files or a grouping in code is seen as discrete from a different directory of files or grouping and code can have visibility modifiers applied to limit access by other areas of code.

Note: You can declare and define Rust modules directly inside Rust code instead of needing to use directories and files but we will generally put each module into its own file.

In Rust, we have to explicitly register each source file via module files named mod.rs - each unique directory in our code base which contains Rust code would have its own mod.rs - with the exception of the root level of the Rust project. By the end of this article the structure of our crust-build project will be:

+ root

    + crust-build

        + core
            - context.rs        // contains properties about the active build
            - failable_unit.rs  // alias for a type that can fail or return a value
            - failable.rs       // alias for a type that can fail
            - logs.rs           // utility to print log messages
            - mod.rs            // registers all sources in the 'core' directory            
            - target.rs         // model representing the target platforms we support
            - variant.rs        // model representing if we are doing debug/release builds

        - android.rs            // Android build implementation
        - emscripten.rs         // Emscripten build implementation
        - ios.rs                // iOS build implementation
        - log_tag.rs            // utility to automate log tags
        - macos_console.rs      // MacOS Console build implementation
        - macos_desktop.rs      // MacOS Desktop build implementation
        - main.rs               // entry point for our command line application        
        - windows.rs            // Windows build implementation

We will work through some of the core utilities and components first as we’ll need them in our main file to get our command line up and running.

Important: When writing Rust code, the standard code style is to use snake_case rather than camelCase. If you are like me and come from a more Kotlin/Java’ish kind of background you will start off finding yourself absent mindedly writing Rust code using camelCase - make sure to correct your muscle memory! For example, this is ok: fn hello_world(), this is NOT ok: fn helloWorld() - as much as you might believe you should just be able to camel case your way through, you should just get on board with the snake_case approach and roll with it - after a while it might even feel like a better way to write code!

core/failable.rs

The Rust language offers a built in Result type which represents the outcome of an operation with either a value or an error. If a consumer doesn’t gracefully handle an error result your application code will panic - often leading to a hard crash. By returning a Result object for code that can potentially generate an error, we give the caller the opportunity to decide how to gracefully (or not!) handle the outcome.

Technically we could write our Rust code in a way that assumes all potential error producing code always works by using language features such as unwrap or expect however this is probably a bad habit to get into because if something did in fact cause an error it would crash the application due to it manifesting as a panic. This would be like having an uncaught exception in other languages. That said, there will be a tiny handful of places where we will force unwrap things but it should be a rare thing.

To use a Rust Result type, we need to specify what the successful type of data is, and what the error type of data is, so if we had a method that should return a 32 bit float, but might produce an error we could write:

fn compute_value() -> Result<f32, Box<dyn Error>> {
    Ok(/* do something that can fail */)
}

Returning a result

The Ok type is used to represent a successful result, if the code inside the Ok invocation fails, the error will bubble out into the returned object instead.

Additionally in Rust code, the output of the last line in a method is actually returned from the method - there is no need to use a return keyword at the end of a method - though if a method needs short circuit and return early you would use a return statement in the short circuit. Some other languages also do this such as Groovy. It takes a bit of getting used to but it is what it is - I’m a bit on the fence about it to be honest.

Returning an error

Something that seems rather curious is the Box<dyn Error> signature. What is a Box? What is dyn?

If we look at the Error type we can see that it is actually a Rust trait. A trait is quite similiar in concept to an interface or protocol from other languages. It defines a set of behaviours and constraints and can be used to achieve polymorphism (or monomorphism via generics which we will use a small splash of later in our project).

Following the gist of this Rust example, a trait means you could pass an object which implements Animal into a method that only needs to know about the behaviours and constraints of an Animal, even though the actual object might be a Sheep or Cat.

In our Failable type alias, we don’t know ahead of time which implementation of the Error trait we might end up with if we encounter an error, but we do know that at the very least it will fulfill the behaviours and constraints of the Error trait.

So, what is the dyn keyword? From the docs:

The dyn keyword is used to highlight that calls to methods on the associated Trait are dynamically dispatched.

The key part of this statement is dynamically dispatched (as opposed to statically dispatched). Objects represented by a dyn trait must be stored in the heap rather than the stack because Rust can’t know ahead of time what size the underlying data structure is if the only information it has about the data type is the trait.

If Rust doesn’t know the size of an object, it cannot know how much space on the stack to allocate to hold it therefore the object cannot be stored in the stack. Objects on the heap can be of any size because under the hood they are indirectly accessed via a memory pointer.

The use of a heap allocated dyn trait is also the reason for needing the Box container inside which to store the error. In Rust if you want to hold any object that is stored on the heap (rather than the stack) you must use one of the memory managed data types such as Box. Here are a few of the most common memory container types that you need to become familiar with in Rust:

Note: It is possible to use monomorphism with generics to allow objects to be stored on the stack because effectively every monomorphic implementation of a trait is actually a concrete type, meaning its size can be known ahead of time. Even so, if you needed to have a shared reference to a monomorphic object you would be forced to contain it within a Rc or Arc anyway, similar to dynamic / polymorphic objects. But we will leave that topic for another day and focus on dynamic traits at the moment :)

This topic can get very complicated very quickly and when starting out with Rust it was one of the hardest things for me to get my head around - a lot of other languages don’t require the author to even be aware of how the data is stored let alone actively write explicit syntax around it - though as a comparative example, on C++ you also have a suite of smart pointer types which feel somewhat similar.

The appropriate choices when picking memory containers definately became a big headache for me during this research project - for example you might start off thinking you don’t need to share a particular object and use a Box type, but then later on find that you need to hold a reference to that object in multiple places so need to change from Box to Rc - which then forces a big code refactor all over the place … eugh …

For now it is sufficient to be aware that the Failable alias type we are about to author has the ability to capture an error which will be an implementation of the Error trait, and since any dynamic implementation of a trait must be stored on the heap, we must hold the error in a Box container and mark it with the dyn keyword :)

Error handling

Whoever calls a method which can return a failable result would have to check that the result was successful to use its returned value, or handle the error if there was one. You will see later how to do this for our code base.

Ergonomic type alias

Writing Result<T, Box<dyn Error>> every time we want to author a method that can return a failable result feels pretty repetitive so we’ll create a helpful alias type to allow us to write something more like:

fn compute_value() -> Failable<f32> {
    Ok(/* do something that can fail */)
}

Create the file core/failable.rs with the following:

use std::{boxed::Box, error::Error};

pub type Failable<T> = Result<T, Box<dyn Error>>;

This is how you declare a type alias in Rust. Now whenever our code specifies Failable<T> it will be automatically interpreted as the more long winded Result<T, Box<dyn Error>>.

core/failable_unit.rs

This is very similar to the Failable<T> type with the only difference that it represents the Unit return type in Rust. Normally we wouldn’t actually specify Unit as the return type of a Rust method but in the context of our type alias we have to be explicit. Note that we use () to represent the Unit type.

Create the file core/failable_unit.rs with the following:

use std::{boxed::Box, error::Error};

pub type FailableUnit = Result<(), Box<dyn Error>>;

This alias then lets us write methods that don’t return any particular value, but still might fail and produce an error:

fn do_some_risky_work() -> FailableUnit {
    // Do something that can fail
    Ok(())
}

Note: although the Unit type can be thought of as having no value, we need to return () to represent it - for a result it would be Ok(()).

core/mod.rs

We’ve just written a couple of new Rust utility methods but there is actually no way for anything to call them yet because we haven’t added them into a mod.rs file to mark their inclusion in our code base. Fix this by adding a new module file crust-build/core/mod.rs with the following content:

pub mod failable;
pub mod failable_unit;

It is now possible for other Rust code in our project to use these methods. Each entry in the module file includes:

When consuming Rust modules programmatically there are a number of ways to specify the path to the module you want to use. The approach to how you write these paths is somewhat of a stylistic choice but I will always be using a very explicit approach which starts with the crate keyword. The crate keyword means root - so we could say starting at the root level, traverse through this set of module paths to the thing I want. So, if I wanted to use the Failable alias I would write: use crate::core::failable::Failable.

Root module

We still need to do one more thing to include our new core module - it needs to be referenced and exposed programmatically at the root level, typically in your main source file. Edit main.rs and add the following to expose the core module - and all of its public child modules - to the Rust project:

pub mod core;

All Rust modules in a code base must have a root level inclusion clause like this - they don’t necessarily have to be pub though. Imagine the modules in your project to be like the branches and leaves of a tree - the path to any code within any module needs to be reachable from the root of the tree.

core/target.rs

To handle the input of the --target command line argument we will create a Rust enumeration representing the set of target platforms we can build, along with a few utility methods to help map a raw string into the appropriate target enum type. The Target enum is then how we know which target platform the caller is wanting to build:

Enter the following:

use crate::core::failable::Failable;

const ANDROID: &str = "android";
const EMSCRIPTEN: &str = "emscripten";
const IOS: &str = "ios";
const MACOS_CONSOLE: &str = "macos-console";
const MACOS_DESKTOP: &str = "macos-desktop";
const WINDOWS: &str = "windows";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
    Android,
    Emscripten,
    Ios,
    MacOSConsole,
    MacOSDesktop,
    Windows,
}

impl Target {
    pub fn resolve(id: &str) -> Failable<Target> {
        match &*id.to_lowercase() {
            ANDROID => Ok(Target::Android),
            EMSCRIPTEN => Ok(Target::Emscripten),
            IOS => Ok(Target::Ios),
            MACOS_CONSOLE => Ok(Target::MacOSConsole),
            MACOS_DESKTOP => Ok(Target::MacOSDesktop),
            WINDOWS => Ok(Target::Windows),
            _ => Err("Unknown target id".into()),
        }
    }

    pub fn id(&self) -> &str {
        match self {
            Target::Android => ANDROID,
            Target::Emscripten => EMSCRIPTEN,
            Target::Ios => IOS,
            Target::MacOSConsole => MACOS_CONSOLE,
            Target::MacOSDesktop => MACOS_DESKTOP,
            Target::Windows => WINDOWS,
        }
    }
}

Rust enumerations are actually capable of modelling rich data types and we could have put an id as a field within each enum member as a struct, however Rust doesn’t permit structs to have user defined default values so we would still end up having to write some other code to create and initialise each enum member struct rather than having their values hard coded into the enum itself.

When we have enum structs containing fields of data it also makes pattern matching against them a bit more tedious, so in our code base we’ll keep things simple and only use enums to model simple types with no associated data fields.

If you like the the idea of coupling associated data with enums, you can check this documentation out for how to do it - I suspect there are probably some decent use cases for when using associated data fields makes more sense.

A note about strings

Something you will see and almost certainly smash into in unpleasant ways when starting out with the Rust language is the way strings work. In the code above we are declaring some constant strings using the &str data type, however strings can also be declared as String. Rather than me explain the difference, it’s worth giving this article a browse. Unless a receiver needs to own a string, you would typically pass a string by reference: &str. But if we need to hand over ownership of a string we would pass by move: String. There’s a lot more to strings than this, but unlike other commonly used languages, the ownership model of Rust makes this a very confusing topic when you first encounter it.

As we work through our project you may notice that in the vast majority of use cases we will prefer to write code that takes objects by reference - via the & operator. So long as the the receiver of a referenced object doesn’t have a life time longer than the object owner, we can do this without issue.

Derive macros

Something that looks a little curious here is this line:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]

Rust has a macro system that provides code gen features to help with some of the more mundane or boilerplate kinds of things that are often needed. Read up on derive attributes here to get a sense of what this enables for us. We can also write our own macros to automate bits of Rust code too - a lot of third party libraries take advantage of this feature to do this.

Implementation

Our target code includes two helper methods - resolve and id. Notice how we have declared these methods inside the following block of code:

impl Target {
    ...
}

In Rust you add functionality to an existing type by using the impl block (implementation). Any methods put into this block will become member methods, but only if the first argument of the method is the special &self argument.

If &self is not the first argument in the method signature, the method becomes a type method - somewhat similar to a class method in other languages. This means you can’t call it from any specific instance of your object, but rather on the type itself.

Note: There is no reason you must include the impl in the same file as the object definition - this allows us to do things like writing extension methods on foreign types which is pretty cool. Most of the time for simplicity we will keep the impl in the same file but not because we are forced to.

As an aside, in Rust there is no such thing as a constructor, so a very common pattern for constructing instances of objects is to declare a new method without the &self argument which returns a new instance of type Self which refers to its implementor type, for example:

pub struct HelloWorld {}

impl HelloWorld {
    pub fn new() -> Self {
        HelloWorld {}
    }
}

// Usage:

let hello_world = HelloWorld::new();

‘resolve’ method

The resolve method does not have the &self argument which means you can’t call resolve on an instance of the Target enum but instead it is called on the Target type itself. The body of the resolve method simply looks at the incoming id string input and tries to match it against one of our string constants.

pub fn resolve(id: &str) -> Failable<Target>

// Usage:

let target = Target::resolve("id");

// 'target' now contains a result which contains either the resolved 'Target' or an error.

If no match can be found, we return an error through the Failable result, this is done by returning an object of type Err with a string error message.

_ => Err("Unknown target id".into()),

‘id’ method

The id method does have the &self argument, meaning that it can only be invoked from an instance of a Target object (unlike our resolve method). The id method is just really the inverse of the resolve - it returns the string constant representation of a given target.

pub fn id(&self) -> &str

// Usage:

let id = some_instance_of_a_target.id();

// 'id' now contains the string representation of the given target instance.

Note: The id method returns a reference string type (&str) which normally might cause problems due to ownership and lifetime constraints, but in this case it is returning a reference to a constant which has the same lifetime as the entire application.

core/variant.rs

Next up we need a way to model the --variant argument that is passed in via the command line. We will allow debug or release variants to be specified. The approach here is a bit similar to our target enumeration:

Enter the following:

const DEBUG: &str = "debug";
const RELEASE: &str = "release";
const RUST_COMPILER_FLAG_DEBUG: &str = "";
const RUST_COMPILER_FLAG_RELEASE: &str = "--release";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Variant {
    Debug,
    Release,
}

impl Variant {
    pub fn resolve(arg: &str) -> Result<Variant, &str> {
        match &*arg.to_lowercase() {
            DEBUG => Ok(Variant::Debug),
            RELEASE => Ok(Variant::Release),
            _ => Err("Unknown variant"),
        }
    }

    pub fn id(&self) -> &str {
        match self {
            Variant::Debug => DEBUG,
            Variant::Release => RELEASE,
        }
    }

    pub fn rust_compiler_flag(&self) -> &str {
        match self {
            Variant::Debug => RUST_COMPILER_FLAG_DEBUG,
            Variant::Release => RUST_COMPILER_FLAG_RELEASE,
        }
    }
}

If you compare this to target.rs you can see it basically does the same thing to map an input into Debug or Release enumerations. The only additional method is the rust_compiler_flag which helps us to know whether to put the --release argument into our Rust compiler commands if we specified the Release variant.

Note: We are not being strict with the casing of the variant argument - for example some toolchains such as Xcode will pass Debug instead of debug etc.

core/logs.rs

Rust offers a few simple methods for printing log statements to stdout such as println but we will create a small utility to help wrap logging commands. Create core/logs.rs and add it to core/mod.rs:

pub fn out(tag: &str, message: &str) {
    println!("[ {} ] {}", tag, message);
}

This will allow us to write code like:

use crate::core::logs;

fn main() {
    logs::out("tag", "Hello world!");
}

// Outputs:
// [ tag ] Hello world!

We’ll also apply a nifty Rust macro trick to give us a way to automatically fill in the tag argument with the name of the current method. I found this discussion thread about using macros while I was researching whether Rust could perform any kind of reflection - it doesn’t support reflection in the way that something like Java does but there are a handful of tools that let us get some information about data types: https://stackoverflow.com/questions/38088067/equivalent-of-func-or-function-in-rust.

Note: This is probably considered a total hack but I like it and it was a cool way to learn about Rust macros!

Create log_tag.rs at the root source level (next to main.rs).

Note: For macro exports to work they have to be at the root level.

#[macro_export]
macro_rules! log_tag {
    () => {{
        #[allow(dead_code)]
        fn this() {}

        #[allow(dead_code)]
        fn type_name_of<T>(_: T) -> &'static str {
            std::any::type_name::<T>()
        }

        // We will extract the fully qualified path name of the 'this' method and break it into components.
        let path_components: std::vec::Vec<&str> = type_name_of(this).split("::").collect();

        // Return the second last element which will be the parent of the `this` method.
        path_components[path_components.len() - 2]
    }};
}

Also register log_tag.rs as a public module in our main.rs file, so the rest of our code base can use it:

pub mod log_tag;

The #[macro_export] declares that the following code should be included in the Rust macro processor so any code gen that is required can be performed. In this case we are using the macro_rules! feature to declare a new macro named log_tag which when executed by the Rust macro processor will:

  1. Generate a new method named this in the same scope where the macro was invoked
  2. Extract the type name information of the generated this method as a string using the type_name utility along with a bit of generics sleight of hand, this produces a string representing the fully qualified module path to the type - for example crate::a_cool_module::a_cool_method::this
  3. Split the string using the :: delimiter - to break the fully qualified type string into a list of its component parts
  4. Return the second last component part from the resulting list, which will be the name of the parent scope of the this method - in the example a_cool_method

Don’t worry too much if this isn’t clear at this point, you can review this later once you have been messing around with Rust a bit more. The log tag macro now lets us do something like this:

use crate::{
    core::logs,
    log_tag,
}

fn a_cool_method() {
    logs::out(log_tag!(), "I am a cool method!");
}

fn another_cool_method() {
    logs::out(log_tag!(), "I am another cool method!");
}

fn main() {
    a_cool_method();
    another_cool_method();
}

// Output is:
// [ a_cool_method ] I am a cool method!
// [ another_cool_method ] I am another cool method!

Note: To invoke a macro in Rust, you use the ! operator after the macro name. You can see this all through the Rust standard library with things like println!, vec! etc, and in our case log_tag!.

core/context.rs

During our Rust build we need to calculate and store a range of properties which describe the build we are doing. Things like:

This will be one of very few (if not the only) things containing state in our builder application - most other code will be stateless. To hold this build state we’ll introduce a Context structure - since it is modelling the contextual information about the build.

Create core/context.rs, add it to core/mod.rs (you know how to do that now don’t you!):

use crate::{
    core::{logs, target::Target, variant::Variant},
    log_tag,
};
use std::path::PathBuf;

pub struct Context {
    pub assets_dir: PathBuf,
    pub rust_build_dir: PathBuf,
    pub source_dir: PathBuf,
    pub target_home_dir: PathBuf,
    pub variant: Variant,
    pub working_dir: PathBuf,
}

impl Context {
    pub fn new(root_dir: PathBuf, target: Target, variant: Variant) -> Self {
        let target_home_dir = root_dir.join(target.id());
        let working_dir = target_home_dir.join(".rust-build");
        let rust_build_dir = working_dir.join("rust");
        let source_dir = root_dir.join("crust-main");
        let assets_dir = source_dir.join("assets");

        Context {
            assets_dir: assets_dir,
            rust_build_dir: rust_build_dir,
            source_dir: source_dir,
            target_home_dir: target_home_dir,
            variant: variant,
            working_dir: working_dir,
        }
    }

    pub fn print_summary(&self) {
        logs::out(log_tag!(), "---------------------------------------------");
        logs::out(log_tag!(), &format!("Assets dir:          {:?}", self.assets_dir));
        logs::out(log_tag!(), &format!("Working dir:         {:?}", self.working_dir));
        logs::out(log_tag!(), &format!("Rust build dir:      {:?}", self.rust_build_dir));
        logs::out(log_tag!(), &format!("Variant:             {:?}", self.variant));
        logs::out(log_tag!(), &format!("Target home dir:     {:?}", self.target_home_dir));
        logs::out(log_tag!(), &format!("Main source dir:     {:?}", self.source_dir));
        logs::out(log_tag!(), "---------------------------------------------");
    }
}

This is an example of a struct which is one of the basic Rust building block types of objects you can create. It is very tempting to call these things classes but it is critical to understand that Rust is not an object oriented programming language - is has no inheritance, constructors or many of the other trappings that an object oriented language typically provides. The term class to me feels like we are in that object oriented mind set - but if you really want to use the word class instead of struct I think generally people will understand what you mean.

Not being an object oriented language will influence the way you need to architect your code, where you must rely on composition over inheritance. If you are from an object oriented background this will probably slam you in the face at some point when you suddenly realise you can’t subclass something to solve a problem around common type behaviour or polymorphism. Some would argue that composition should generally be preferred over inheritance anyway - so Rust could be a nirvana for those people.

Note: There are language features - such as traits - which provide ways to share behaviour between types. Also if you have done any programming with Go you will feel right at home with the way Rust works in terms of composition over inheritance …

Our Context model will contain the following properties, which will all be computed when our command line application starts up. Some properties are influenced by the selected target platform and the build type:

It may not be super clear why we need some of these properties just yet, but when we start implementing the target platforms you’ll see how it hangs together.

Note: Observe that we are using the new factory pattern as a way to construct an instance of the Context object.

Stub out target platforms

Before we wire up the command line code we will stub out each of our target platforms, so we can actually invoke something. Our stubbed code will simply define a build method which prints out the summary of the build context passed in. Create the following files next to main.rs.

Remember: Code at the root level doesn’t need to go into a mod.rs file.

Populate each of the files with the following:

use crate::core::{context::Context, failable_unit::FailableUnit};

pub fn build(context: &Context) -> FailableUnit {
    context.print_summary();
    Ok(())
}

Note that the build method takes a reference to a context object and returns a FailableUnit - indicating that something within the build method implementation could generate an error. For now we are just invoking the print_summary method on the context object and returning a successful result via Ok(()) (remember that () is the literal for a Unit type).

Register the new modules in main.rs, but this time there is no need for the pub modifier because no code outside the root level will need access to them:

mod android;
mod emscripten;
mod ios;
mod macos_console;
mod macos_desktop;
mod windows;

main.rs

Ok, so that was a pretty big preamble but we needed all those core data structures and utilities in place to let us start wiring up our command line interface. Next up we will:

I am not going to replicate all the documentation about the clap library itself - you can browse the Github page to learn about it more. Update main.rs with:

pub mod core;
pub mod log_tag;

mod android;
mod emscripten;
mod ios;
mod macos_console;
mod macos_desktop;
mod windows;

use crate::core::{context::Context, failable_unit::FailableUnit, logs, target::Target, variant::Variant};
use clap::{App, AppSettings, Arg};
use std::path::PathBuf;

#[cfg(target_os = "windows")]
fn get_supported_platform_ids() -> Vec<&'static str> {
    vec![Target::Android.id(), Target::Emscripten.id(), Target::Windows.id()]
}

#[cfg(target_os = "macos")]
fn get_supported_platform_ids() -> Vec<&'static str> {
    vec![
        Target::Android.id(),
        Target::Emscripten.id(),
        Target::Ios.id(),
        Target::MacOSConsole.id(),
        Target::MacOSDesktop.id(),
    ]
}

fn main() {
    std::env::set_var("RUST_BACKTRACE", "full");

    let cli = App::new("crust-build")
        .version("1.0.0")
        .author("Marcel Braghetto")
        .about("CLI for building 'CRUST' targets.")
        .setting(AppSettings::ArgRequiredElseHelp)
        .arg(
            Arg::with_name("target")
                .long("target")
                .takes_value(true)
                .required(true)
                .possible_values(&get_supported_platform_ids())
                .case_insensitive(true)
                .help("Target:"),
        )
        .arg(
            Arg::with_name("variant")
                .long("variant")
                .takes_value(true)
                .possible_values(&[Variant::Debug.id(), Variant::Release.id()])
                .case_insensitive(true)
                .default_value(Variant::Debug.id())
                .help("Variant:"),
        )
        .get_matches();

    std::process::exit(match build(&cli) {
        Ok(_) => 0,
        Err(err) => {
            logs::out(log_tag!(), &format!("Fatal error: {:?}", err));
            1
        }
    });
}

fn build(cli: &clap::ArgMatches) -> FailableUnit {
    let current_dir = match std::env::var("CARGO_MANIFEST_DIR") {
        Ok(manifest_path) => PathBuf::from(manifest_path),
        _ => {
            panic!("Run crust-build via 'cargo run'!");
        }
    };

    let target = Target::resolve(cli.value_of("target").ok_or("Target arg not found.")?)?;
    let variant = Variant::resolve(cli.value_of("variant").ok_or("Variant arg not found.")?)?;
    let context = Context::new(current_dir.parent().ok_or("Missing parent dir")?.to_path_buf(), target, variant);

    match target {
        Target::Android => android::build(&context),
        Target::Emscripten => emscripten::build(&context),
        Target::Ios => ios::build(&context),
        Target::MacOSConsole => macos_console::build(&context),
        Target::MacOSDesktop => macos_desktop::build(&context),
        Target::Windows => windows::build(&context),
    }
}

Target platforms

Ok, so let’s digest some of this. First off you might notice a couple of methods that actually have the exact same name:

#[cfg(target_os = "windows")]
fn get_supported_platform_ids() -> Vec<&'static str> {
    vec![Target::Android.id(), Target::Emscripten.id(), Target::Windows.id()]
}

#[cfg(target_os = "macos")]
fn get_supported_platform_ids() -> Vec<&'static str> {
    vec![
        Target::Android.id(),
        Target::Emscripten.id(),
        Target::Ios.id(),
        Target::MacOSConsole.id(),
        Target::MacOSDesktop.id(),
    ]
}

How can we have two methods named get_supported_platform_ids? Well, each one has a slightly different build configuration profile assigned to it using the cfg attribute. At compile time only the code that matches the configuration attribute expression will be included. We don’t want to use conditional compilation code if we can avoid it, but we are building a cross platform application so there will be times where we don’t have a choice.

The get_supported_platform_ids method provides a list of which target platforms Windows can build versus MacOS. Whilst there is some overlap in what a Windows or MacOS host can build, there are targets that one or the other can’t build - for example when building on a Windows computer there is no way to build the iOS target even though you could technically run the crust-build application and specify --target ios.

The get_supported_platform_ids method is called in the main method when defining the target command line argument in the possible_values setting:

.arg(
    Arg::with_name("target")
        .long("target")
        .takes_value(true)
        .required(true)
        .possible_values(&get_supported_platform_ids())
        .case_insensitive(true)
        .help("Target:"),
)

We are declaring that the argument with name target is required and takes a value from the caller. The valid values come from the id method we wrote in the Target enum earlier - android, emscripten, ios, macos-console, macos-desktop, windows.

Build variant

The build variant argument expects a case insensitive debug or release values but will default to debug if not specified:

.arg(
    Arg::with_name("variant")
        .long("variant")
        .takes_value(true)
        .possible_values(&[Variant::Debug.id(), Variant::Release.id()])
        .case_insensitive(true)
        .default_value(Variant::Debug.id())
        .help("Variant:"),
)

Root location guard

The build method is passed the collection of matching arguments from clap and is responsible for figuring and and launching the appropriate target build code. The first thing we need to figure out is where the project root directory is so we can orient all the file locations in the context. Figuring out the root is a bit tricky because you could actually be executing the crust-build binary from anywhere in the file system.

We are going to enforce the rule that to legally run our crust-build application you should be using the cargo run command instead of executing a precompiled binary version of it - though that is certainly an option with merit. In fact initially I did take that approach but later moved away from it because I had to write some orchestration shell scripts to copy the compiled crust-build binary into the root directory of the project each time. I also felt that using cargo run was fine because you need it anyway to actually compile the main application code.

fn build(cli: &clap::ArgMatches) -> FailableUnit {
    let current_dir = match std::env::var("CARGO_MANIFEST_DIR") {
        Ok(manifest_path) => PathBuf::from(manifest_path),
        _ => {
            panic!("Run crust-build via 'cargo run'!");
        }
    };

We are looking for an environment variable named CARGO_MANIFEST_DIR which typically would only be present if we had launched the application via cargo run. If this environment variable doesn’t exist we are going to deliberately panic. If we know where the Cargo manifest is located then it gives us a point of reference in the file system of where the rest of our code base is, making it possible to compute all the paths required by our build context.

Note: If you want to run the crust-build application from a terminal session in a directory other than the crust-build directory, you can use the Cargo –manifest-path argument to specify where the Cargo.toml file is for crust-build - that is then sufficient for us to deterministically figure out the correct root dir.

Extract and map target and variant arguments

Next we look for the target and variant command line argument values and try to look them up against our Target and Variant enumerations we wrote earlier:

let target = Target::resolve(cli.value_of("target").ok_or("Target arg not found.")?)?;
let variant = Variant::resolve(cli.value_of("variant").ok_or("Variant arg not found.")?)?;

Note that we use ok_or to safely unwrap data modelled using the Rust Option type. An Option is a bit like a Result but doesn’t accommodate an error - it simply models an object that may or may not contain a value.

Note: There is no null or nil in Rust - this is a good thing as it prevents being able to create objects in an uninitialised state (interop with languages such as C/C++ can bend this rule as they are bridged through unsafe incantations). To model the absence of a value we need to use data structures such as Option.

Notice also the use of the question mark (?) operator - whenever you see ? at the end of an expression, it means you are trying to safely unwrap a result and if an error is found to automatically halt execution at that point in the code and bubble the error up and out of the scope it is within. The concept is somewhat similar to a safe navigation operator in other languages except that in Rust an error would normally bubble out as a failure result if a problem was encountered, whereas in other languages the code after the operator would simply not be executed.

For example, if Target::resolve returned an error result, the ? means that the build method would immediately return a FailableUnit with an error state as its value. If there is no error present, then the actual value in the result is assigned to the expression (instead of a Result object).

Create context

Once we have a valid target and variant, we can construct a new Context object to model the build:

let context = Context::new(current_dir.parent().ok_or("Missing parent dir")?.to_path_buf(), target, variant);

Note that again we are using ok_or to safely unwrap the optional result of invoking parent against the current directory.

Run the appropriate build code

At this point we can evaluate which target we are trying to build and kick off the appropriate code path for it:

match target {
    Target::Android => android::build(&context),
    Target::Emscripten => emscripten::build(&context),
    Target::Ios => ios::build(&context),
    Target::MacOSConsole => macos_console::build(&context),
    Target::MacOSDesktop => macos_desktop::build(&context),
    Target::Windows => windows::build(&context),
}

We don’t need to include Ok(()) at the end of the build method, because the match expression is exhaustive and the returned result of calling the build method on any/all of the targets is of type FailableUnit and can therefore be used as the return value from the build method.

Ok, so now if you run the program via our Visual Studio Code debug profile although you will still get an error regarding the missing debug binary, you will see that our command line argument code is working, and that the context details are being printed out into the console window:

> Executing task in folder crust-build: cargo run -- --target macos-console --variant debug <

    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/crust-build --target macos-console --variant debug`
[ print_summary ] ---------------------------------------------
[ print_summary ] Assets dir:          "<snip>/crust-main/assets"
[ print_summary ] Working dir:         "<snip>/macos-console/.rust-build"
[ print_summary ] Rust build dir:      "<snip>/macos-console/.rust-build/rust"
[ print_summary ] Variant:             Debug
[ print_summary ] Target home dir:     "<snip>/macos-console"
[ print_summary ] Main source dir:     "<snip>/crust-main"

CLI validation

To see how the CLI behaves with other inputs, you can open a terminal session in the crust-build directory and try the following commands to test drive how the argument matching works and what the clap library automatically does to help us:

No arguments

$ cargo run

error: The following required arguments were not provided:
    --target <target>

USAGE:
    crust-build --target <target> --variant <variant>

For more information try --help

Show help

$ cargo run -- --help

crust-build 1.0.0
Marcel Braghetto
CLI for building 'CRUST' targets.

USAGE:
    crust-build [OPTIONS] --target <target>

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
        --target <target>      Target: [possible values: android, emscripten, ios, macos-console, macos-desktop]
        --variant <variant>    Variant: [default: debug]  [possible values: debug, release]

Note: On Windows you would see a different set of --target possible values.

Invalid target

$ cargo run -- --target banana

error: 'banana' isn't a valid value for '--target <target>'
	[possible values: android, emscripten, ios, macos-console, macos-desktop]

USAGE:
    crust-build --target <target> --variant <variant>

For more information try --help

Invalid variant

$ cargo run -- --target macos-console --variant banana

error: 'banana' isn't a valid value for '--variant <variant>'
	[possible values: debug, release]

USAGE:
    crust-build --target <target> --variant <variant>

For more information try --help

Trivia: I don’t actually like eating bananas but I do like the way the word banana sounds!

Summary

Ok, we now have all the scaffolding in place to drive our command line builder application, next up we will start filling in a couple of our target platform implementations to give us our ‘dev’ applications which we’ll use as the default targets while developing our main Rust application and reach a point where we can launch it in the debugger.

The code for this article can be found here.

Continue to Part 4: Launch debugger.

End of part 3