Writing Rust CLIs - Hello World !

Writing Rust CLIs - Hello World !

organizing Rust Project Directory

create a directory structure with the following commands

$ mkdir -p hello/src

mkdir the command will make a directory the -p options create a parent directory before creating a child directory

create hello.rs with the following content :


fn main(){
     println!("hello,world!");
}

move hello.rs source file into hello/src using the mv command :

$  mv hello.rs hello/src

use the cd command to change into that directory and compile your program again :

$ cd hello 
$ rustc src/hello.rs

you should now have a hello executable in the directory.

$ tree
➜  hello tree 
.
├── hello
└── src
    └── hello.rs

Creating and Running a project with Cargo

start a new rust project is to use the Cargo tool.

➜  hello cd ..
➜  rustlabs rm -rf hello

delete the existing project. the -r the recursive option will remove the contents of a directory and -f force option will skip any errors

Then start your project in a new using Cargo like so :

$ cargo new hello
     Created binary (application) `hello` package

this should create a new hello the directory that you can change into

  Created binary (application) `hello` package
➜  rustlabs tree
.
└── hello
    ├── Cargo.toml
    └── src
        └── main.rs

2 directories, 2 files
➜  rustlabs cd hello
➜  hello git:(master) ✗ tree    
.
├── Cargo.toml
└── src
    └── main.rs

1 directory, 2 files

Cargo.toml is a configuration file for the project. the extension .toml stands for Tom's Obvious, Minimal Language

the src the directory is for rust source code files

main.rs is the default starting point for the rust program

hello git:(master) ✗ cat src/main.rs 
fn main() {
    println!("Hello, world!");
}

we use to compile projects using rustc to combine the program. to run in one command using cargo run

 hello git:(master) ✗ cargo run 
   Compiling hello v0.1.0 (/Users/sangambiradar/Documents/rustlabs/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.84s
     Running `target/debug/hello`
Hello, world!

if you would like for Cargo to not print status messages about compiling and running the code use the -q or --quiet options

cargo run --quiet
Hello, world!

use ls command to list the content of the current working directory

hello git:(master) ✗ ls
Cargo.lock Cargo.toml src        target

you will see the directory target/debug that contains the build artifacts

➜  hello git:(master) ✗ ./target/debug/hello
Hello, world!

why was the binary file called hello , though, and not main? to answer that at Cargo.toml

➜  hello git:(master) ✗ ls
Cargo.lock Cargo.toml src        target
➜  hello git:(master) ✗ cat Cargo.toml 
[package]
name = "hello"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
  • name = "hello" of the project created with Cargo so it will also be the name of executable

  • version = "0.1.0" is the version of the program

  • edition = "2021" ate how the rust community introduce changes that are not backward compatible

  • # comment line

  • [dependencies] where you will list any external crates your project uses this project has none at this point so it's blank

Rust libraries are called crates and they use semantic version numbers in form major.minor.patch so that 1.2.4. a change in a major version indicates breaking changes in the create's public programming interface learn more - https://crates.io


Writing and Running Integration tests

let's create a test directory, goal is to test the hello program by running it on the command line as the user will do . create the file tests/cli.rs

#[test]
fn works(0
  assert!(true);
}
  • #[test] attribute tells rust to run this function when testing

  • assert! macro assert that a boolean expression is true

now our project look lake this

➜  hello git:(master) ✗ mkdir tests 
➜  hello git:(master) ✗ cargo run 
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/hello`
Hello, world!
➜  hello git:(master) ✗ tree -L 2
.
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
├── target
│   ├── CACHEDIR.TAG
│   └── debug
└── tests
    └── cli.rs

4 directories, 5 files
➜  hello git:(master) ✗
  • Cargo.lock file records the exact versions of the dependencies used to build your pogram .[ Note:- you should not edit this file ]

  • the src the directory is for the Rust Source code files to build the program.

  • the target the directory holds the building artifacts

  • the tests the directory holds the Rust Source code for testing the program

run test

$ cargo run
➜  hello git:(master) ✗ cargo test 
   Compiling hello v0.1.0 (/Users/sangambiradar/Documents/rustlabs/hello)
    Finished test [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/main.rs (target/debug/deps/hello-2d4f25cc5b31a396)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/cli.rs (target/debug/deps/cli-4a0f9bf0df349d1e)

running 1 test
test works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

the micro assert! will verify that expectation is true or assert_eq! to verify that something is an expected value since this test is evaluating the literal value test , it will always succeed .

#[test]
fn works() {
    assert!(false);
}

run cargo test again

 hello git:(master) ✗ cargo test 
   Compiling hello v0.1.0 (/Users/sangambiradar/Documents/rustlabs/hello)
    Finished test [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/main.rs (target/debug/deps/hello-2d4f25cc5b31a396)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/cli.rs (target/debug/deps/cli-4a0f9bf0df349d1e)

running 1 test
test works ... FAILED

failures:

---- works stdout ----
thread 'works' panicked at 'assertion failed: false', tests/cli.rs:3:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    works

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--test cli`

Replace the contents of tests/cli.rs with the following code :

use std::process::Command;
#[test]
fn runs() {
    let mut cmd = Command::new("ls");
    let res = cmd.output();
    assert!(res.is_ok());

}
  • Import std::process::Commnd. The std tells us this is a standard library and is Rust code that is so universally useful it is included with the language

  • create a new Command to run ls The let the keyword will bind a value to a variable and mut will make variably mutable so that it can change

run test and varify your passong test

 cargo test
   Compiling hello v0.1.0 (/Users/sangambiradar/Documents/rustlabs/hello)
    Finished test [unoptimized + debuginfo] target(s) in 0.53s
     Running unittests src/main.rs (target/debug/deps/hello-2d4f25cc5b31a396)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/cli.rs (target/debug/deps/cli-4a0f9bf0df349d1e)

running 1 test
test runs ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

lets update tests/cli.rs with the following code so that the runs function executes hello instead of ls

use std::process::Command;
#[test]
fn runs() {
    let mut cmd = Command::new("hello");
    let res = cmd.output();
    assert!(res.is_ok());

}

when you run a test case its get fail because hello the program can't be found :

 hello git:(master) ✗ cargo test
   Compiling hello v0.1.0 (/Users/sangambiradar/Documents/rustlabs/hello)
    Finished test [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/main.rs (target/debug/deps/hello-2d4f25cc5b31a396)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/cli.rs (target/debug/deps/cli-4a0f9bf0df349d1e)

running 1 test
test runs ... FAILED

failures:

---- runs stdout ----
thread 'runs' panicked at 'assertion failed: res.is_ok()', tests/cli.rs:6:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    runs

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--test cli`
➜  hello git:(master) ✗

recall the binary

➜  hello git:(master) ✗ hello
zsh: command not found: hello

when you execute any command your operating system look in predefined set of directories for something by neme


 hello git:(master) ✗ echo $PATH | tr : '\n'
/Users/sangambiradar/.docker/bin
/Users/sangambiradar/Downloads/google-cloud-sdk/bin
/Library/Frameworks/Python.framework/Versions/3.11/bin
/opt/homebrew/bin
/opt/homebrew/sbin
/usr/local/bin
/System/Cryptexes/App/usr/bin
/usr/bin
/bin
/usr/sbin
/sbin
/Users/sangambiradar/.docker/bin
/Users/sangambiradar/Downloads/google-cloud-sdk/bin
/Library/Frameworks/Python.framework/Versions/3.11/bin
/opt/homebrew/bin
/opt/homebrew/sbin
/Users/sangambiradar/.cargo/bin
➜  hello git:(master) ✗

if we change the directory also it will not work if you refer current directory with binary, not the command

➜  hello git:(master) ✗ cd target/debug 
➜  debug git:(master) ✗ hello
zsh: command not found: hello

run executable binary

➜  debug git:(master) ✗ ./hello
Hello, world!

Adding a Project Dependency

we have seen so far only in the target/debug directory. if we can copy it to $PATH directory ) so it will execute and test run successfully . but don't want to copy my program and test it.

I can use crate assert_cmd to find the program in my crate directory.

[package]
name = "hello"
version = "0.1.0"
edition = "2021"


[dependencies]

[dev-dependencies]
assert_cmd = "1"

using this crate to create a command that looks in Cargo binary directories. that the following test does not verify that the program produces the correct output. update tests/cli.rs the run function will use assert_cmd::Command instead of std::process::Command

use assert_cmd::Command;
#[test]
fn runs() {
    let mut cmd = Command::cargo_bin("hello").unwrap();
    cmd.assert().success();

}
  • import assert_cmd::Command

  • Create Command to run hello in the current crate this returns a Results and code call Result::unwrap because the binary should be found if not found test will fail

  • use assert::success to ensure command successful

Run cargo run to verify test cases


hello git:(master) ✗ cargo test
   Compiling hello v0.1.0 (/Users/sangambiradar/Documents/rustlabs/hello)
    Finished test [unoptimized + debuginfo] target(s) in 0.32s
     Running unittests src/main.rs (target/debug/deps/hello-73f1e61ebe7b71e0)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/cli.rs (target/debug/deps/cli-734782d1ff788617)

running 1 test
test runs ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s

Understanding Program exit values

what does it mean for a program to run successfully? command-line programs should report the final status to the operating system to indicate success or failure. ( POSIX) standards indicate that the standard exit code is 0 to. indicate success

TRUE(1)                                                                                   General Commands Manual                                                                                   TRUE(1)

NAME
     truereturn true value

SYNOPSIS
     true

DESCRIPTION
     The true utility always returns with an exit code of zero.

     Some shells may provide a builtin true command which is identical to this utility.  Consult the builtin(1) manual page.

SEE ALSO
     builtin(1), csh(1), false(1), sh(1)

STANDARDS
     The true utility is expected to be IEEE Std 1003.2 (“POSIX.2”) compatible.

macOS 13.1                                                                                      June 9, 1993                                                                                     macOS 13.1

if you see the above command notes do nothing except return the exit code zero but I can inspect the bash variable $? to see the exit status of the recent command

$ true 
$ echo $?
0

the false command is will give 1

$ false 
$ echo $?
1

let's create src/bin using mkdir src/bin then create src/bin/true.rs with content:

fn main() {
    std::process::exit(0) 
}

run the program and manually check the exit value

➜  hello git:(master) ✗ cargo run --quiet --bin true 
➜  hello git:(master) ✗ echo $?
0

the --bin the option is the name of the binary target to run

add the following test to test/cli.rs to ensure it works correctly

cargo test

  hello git:(master) ✗ cargo test                   
   Compiling hello v0.1.0 (/Users/sangambiradar/Documents/rustlabs/hello)
    Finished test [unoptimized + debuginfo] target(s) in 0.48s
     Running unittests src/main.rs (target/debug/deps/hello-73f1e61ebe7b71e0)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/bin/true.rs (target/debug/deps/true-68835b704201d7bf)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/cli.rs (target/debug/deps/cli-e061012399bb0d29)

running 1 test
test true_ok ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s

➜  hello git:(master) ✗

rust programmes will exit with the value zero by default . recall that src/main.rs doesn't explicitly call std::process::exit . this means that the true program can do nothing to change src/bin/true.rs to following

fun main() {}

run the test and verify its still passes

➜  hello git:(master) ✗ cargo run -q --bin true 
➜  hello git:(master) ✗ echo $?                 
0

let's write a false program at the following path src/bin/false.rs

fn main() {
    std::process::exit(1);
 }

exit with any value between 1 and 255 to indicate an error

✗ cargo run -q --bin false
error[E0601]: `main` function not found in crate `r#false`
  |
  = note: consider adding a `main` function to `src/bin/false.rs`

For more information about this error, try `rustc --explain E0601`.
error: could not compile `hello` due to previous error
➜  hello git:(master) ✗ echo $?                 
101

then add this test to tests/cli.ts to verify that the program reports a failure when run

$ cargo test

another way to write false program use std::process::abort change src/bin/false

fn main() {
    std::process::abort();
 }

Testing the program output

The hello world program exits correctly, I'd like to ensure it prints the correct output to STDOUT which is the standard place for output to appear and it is usually the console. update your runs function in tests/cli.rs to following :

#[test]
fn runs() {
   let mut cmd = Command::cargo_bin("hello").unwrap();
   cmd.assert().success().stdout("Hello,World!\n");
}

run the tests and verify the hello does indeed work correctly. change src/main.rs

fn main() {
    println!("Hello, world!!!");
}

run the tests again to observe a failing test

 hello git:(master) ✗ cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.04s
     Running unittests src/bin/false.rs (target/debug/deps/false-8eec864b008dbf20)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/hello-73f1e61ebe7b71e0)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/bin/true.rs (target/debug/deps/true-68835b704201d7bf)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/cli.rs (target/debug/deps/cli-90bd290aca80271c)

running 1 test
test runs ... FAILED

failures:

---- runs stdout ----
thread 'runs' panicked at 'Unexpected stdout, failed diff original var
├── original: Hello,World!
├── diff: 
│   ---         orig
│   +++         var
│   @@ -1 +1 @@
│   -Hello,World!
│   +Hello, world!!!
└── var as str: Hello, world!!!

command=`"/Users/sangambiradar/Documents/rustlabs/hello/target/debug/hello"`
code=0
stdout=```"Hello, world!!!\n"

stderr="" ', /Users/sangambiradar/.cargo/registry/src/github.com-1ecc6299db9ec823/assert_cmd-1.0.8/src/assert.rs:124:9 note: run with RUST_BACKTRACE=1 environment variable to display a backtrace

failures: runs

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.41s

error: test failed, to rerun pass --test cli ➜ hello git:(master) ✗ cargo test


`-Hello,World!` is the expected output from the program

`+Hello, world!!!` is the output the program actually created

\`command=\`"/Users/sangambiradar/Documents/rustlabs/hello/target/debug/hello"\`\` is shortened version of the command

`code=0` exit code from the program was 0

stdout=\`\`\`"Hello, world!!!\\n"\`\` is the test that was received on `STDOUT`

---

#### exit values make programs composable

the exit value is important because a failed process used in conjunction with another process should cause the combination to failed . for instance, i can use the logical operator && in bash to chain two commands true and ls. Ony if the first process reports success will the second process run

```bash
hello git:(master) ✗ true && ls
Cargo.lock Cargo.toml src        target     tests

Did you find this article valuable?

Support CloudNativeFolks Community by becoming a sponsor. Any amount is appreciated!