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.
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:
--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--variant
argument to determine if a debug
or release
build is desiredcontext
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.
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!
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:
Box
can have only 1 owner - once the owner goes out of scope, the boxed data will be destroyedR
eference C
ounted - the data inside an Rc
can have multiple owners, each owner increases a reference count against the container. Once the reference count reaches zero it means there are no more owners of the data and it is destroyed.Rc
except it provides thread safety, so data inside an Arc
container can safely be shared amongst threads. The trade off is a performance penalty due to the need for thread safety mechanisms.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
orArc
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 fromBox
toRc
- 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>>
.
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 beOk(())
.
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:
pub
here to indicate that any parent module is allowed to see and use this code. If we omitted the pub
keyword, then only modules at the same level, or children of this module could access the code. This is an important concept to keep in mind as it helps to encapsulate areas of your code base from other areas. We won’t make heavy use of this in our project but it’s good to be aware of itmod
keyword: This simply declares a module entryfailable
means to associate the crust-build/core/failable.rs
file and expose it as crate::core::failable
in our Rust code.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
.
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.
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:
core/target.rs
core/mod.rs
file to include the new target
module: pub mod target;
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 theimpl
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.
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:
core/variant.rs
core/mod.rs
to register the variant module: pub mod variant;
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 ofdebug
etc.
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:
this
in the same scope where the macro was invokedthis
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
::
delimiter - to break the fully qualified type string into a list of its component partsthis
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 likeprintln!
,vec!
etc, and in our caselog_tag!
.
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:
assets_dir
: Maps to crust-main/assets
where we will put image and shader files etcrust_build_dir
: The location to output Rust compilation commands: <target platform id>/.rust-build/rust
source_dir
: The location of the crust-main
projecttarget_home_dir
: The location related to the current build target - its home: <target platform id>
variant
: The crate::core::variant::Variant
type representing if this is a debug or release buildworking_dir
: The location where the build can do all of its ephemeral build work: <target platform id>/.rust-build
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 theContext
object.
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.
android.rs
emscripten.rs
ios.rs
macos_console.rs
macos_desktop.rs
windows.rs
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;
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:
--target
and --variant
arguments--target
and --variant
arguments into formal data structures so we can create a valid Context
objectI 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 thecrust-build
directory, you can use the Cargo –manifest-path argument to specify where theCargo.toml
file is forcrust-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
ornil
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 asOption
.
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"
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!
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