Follow Up: Building a Command-Line Application in Rust

Photo by Jp Valery on Unsplash

Follow Up: Building a Command-Line Application in Rust

July 3, 2023

Updated 2023-07-04

Late last year, I wrote about creating a command-line application in rust. Since then, I have been using it every week to post my most played artists on Mastodon. Over the past six months, I have made several updates to the application, and I thought it would be appropriate to inform everyone about the changes.

Note: If you prefer to skip the blog post and only read the PR Diff, you can find it here.

Environment Variables

One of the major updates I wanted to implement was the inclusion of a configuration file for users. This file would contain default values to be used when flags are not provided. Ideally, running ./lfmc would read values from the config file and seamlessly work.

To achieve this, I installed the dirs crate, which is a small, low-level library that provides platform-specific standard directory locations for configuration files on Linux, Windows, and macOS. This not only allows us to update the location of our config file, but it also provides cross-compatibility.

The application fetches the user’s HOME directory and then looks for a .env file in the $HOME/.config/lfmc/ folder. The code then reads the environment variables to be used by the application. Below is the code that gets this done:

if let Some(home_dir) = dirs::home_dir() {
    dotenv::from_filename(
        format!("{}/.config/lfmc/.env", home_dir.to_string_lossy())
    ).ok();
}

Furthermore, I added default values to the Config struct. The updated Config struct now appears as follows:

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
    /// Your Last.fm API Key
    #[arg(short = 'k', long, env = "API_KEY")]
    api_key: String,

    /// Your Last.fm Username
    #[arg(short, long, env = "USERNAME")]
    username: String,

    /// The limit of Artists
    #[arg(short, long, default_value = "5", env = "LIMIT")]
    limit: u16,

    /// The lookback period
    #[arg(short, long, default_value = "7day", env = "PERIOD")]
    period: String,
}

Better Error handling

Another big change I made is dealing with the unneeded unwrap functions throughout the code. I wanted the application to make use of comprehensive error handling. For error handling, I added the Anyhow crate and made the following changes:

fn construct_output(config: Config, json: Value) -> Result<String> {
   ...
   let artists = json["topartists"]["artist"].as_array().ok_or(
       anyhow!("Error parsing JSON.")
   )?;

   for (i, artist) in artists.iter().enumerate() {
       ...
       let name = artist["name"].as_str().ok_or(
           anyhow!("Artist not found.")
       )?;

       let playcount = artist["playcount"].as_str().ok_or(
           anyhow!("Playcount not found.")
       )?;
       ...
   }
   ...
}

Thanks to Harald Hoyer on Mastodon for the above code, it is much cleaner than my match statement I had before!

Conclusion

I am extremely pleased with these improvements as they have significantly enhanced the robustness of this command-line application. Additionally, they have eliminated many of the previously existing bad practices in the application.

Once again, please let me know if there is anything you would change. You can find me on Mastodon, and I would love to chat about Rust!