Why do we need configuration? Creating and handling configuration files in Rust

Why do we need configuration? Creating and handling configuration files in Rust

Providing users with configuration is a common standard when it comes to software. With Rust's serde ecosystem, it's easier than you'd expect.

ยท

4 min read

Configuration is nearly always a necessity when it comes to both public and private software.

Just imagine, you install a program that plays a sound every 30 seconds. Then suddenly your needs shift, and now you need it to play every 15 seconds. Oh no! This program doesn't allow you to change the rate of which the sound plays. You are now left with two choices:

  1. Edit the program yourself, which could take a lot of time
  2. Find another program which allows you to configure the interval of the sound

From a developer standpoint, if configuration were present in this situation, fewer users would be turned away from the lack of flexibility in our software.

NOTE

Level: Beginner-Intermediate

In this project we'll use the toml crate. When it comes to handling configurations, this is not your only option. Take a look at these popular crates:

Getting started

Creating the Cargo project

In your terminal, run the following command:

cargo init <PROJECT_NAME>

Adding dependencies

For our project we will need two dependencies:

  • serde
  • toml

Add the following to your Cargo.toml:

serde = { version = "1.0.147", features = ["derive"] }
toml = "0.5.9"

Defining our config structure

What will our program do?

For this example, our program will print a given word N amount of times.
Our target config will look like this:

word = "Banana!"
repeat = 3

And the output:

Banana!
Banana!
Banana!

Definition

First, import the Serialize and Deserialize traits from the serde crate.
NOTE: This will only work if you have the derive feature enabled for serde. We added this feature earlier.

Create your struct and name it something along the lines of AppConfig. This struct will store the values for your config.

Above the struct definition, derive the Serialize and Deserialize traits we just imported.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct AppConfig {
    word: String,
    repeat: usize,
}

But there's a problem with our code, we don't provide the user with default values. We can fix that easily by implementing Default for our struct.

impl Default for AppConfig {
    fn default() -> Self {
        Self {
            word: "Banana".to_string(),
            repeat: 5
        }
    }
}

Great! We've finished defining the structure of our configuration. It's that easy.

Loading and initializing the configuration file

Creating the ConfigError enum

When trying to load the configuration, two errors can occur:

  • An IO error
  • The configuration is invalid

We will create an enum which covers both of these error scenarios.
This is necessary, because you want to inform the user of what's going wrong, instead of leaving them scratching their head.

use std::io;

enum ConfigError {
    IoError(io::Error),
    InvalidConfig(toml::de::Error)
}

// These implementations allow us to use the `?` operator on functions that
// don't necessarily return ConfigError.
impl From<io::Error> for ConfigError {
    fn from(value: io::Error) -> Self {
        Self::IoError(value)
    }
}

impl From<toml::de::Error> for ConfigError {
    fn from(value: toml::de::Error) -> Self {
        Self::InvalidConfig(value)
    }
}

Definition

With the error enum defined, we're able to create a function to either load or initialize the configuration file.

The function will go as follows:

If the config file exists, read the content of the file, parse the TOML, then return the AppConfig.

If the file does not exist, save the default configuration to the file.

The toml crate provides two methods which provide us the functionality we need:

  • toml::from_str(&S)
  • toml::to_string(&T)
use std::path::Path;
use std::{fs, io};

fn load_or_initialize() -> Result<AppConfig, ConfigError> {
    let config_path = Path::new("Config.toml");

    if config_path.exists() {
        // The `?` operator tells Rust, if the value is an error, return that error.
        // You can also use the `?` operator on the Option enum.

        let content = fs::read_to_string(config_path)?;
        let config = toml::from_str(&content)?;

        return Ok(config);
    }

    // The config file does not exist, so we must initialize it with the default values.

    let config = AppConfig::default();
    let toml = toml::to_string(&config).unwrap();

    fs::write(config_path, toml)?;
    Ok(config)
}

Implementing the program functionality

Implementing the program's actual functionality is pretty simple.
First, load the configuration (or handle the errors), then continue with the program logic.

fn main() {
    let config = match load_or_initialize() {
        Ok(v) => v,
        Err(err) => {
            match err {
                ConfigError::IoError(err) => {
                    eprintln!("An error occurred while loading the config: {err}");
                }
                ConfigError::InvalidConfig(err) => {
                    eprintln!("An error occurred while parsing the config:");
                    eprintln!("{err}");
                }
            }
            return;
        }
    };

    for _ in 0..config.repeat {
        println!("{}", config.word);
    }
}

Output

Hello readers!
Hello readers!
Hello readers!
Hello readers!
Hello readers!

Config.toml

word = "Hello readers!"
repeat = 5

That's it! We've successfully implemented configuration for our program.

Note that this solution is extremely scalable. With the power of serde, we can make extremely complex configuration files.

Conclusion

That's it for this article! Make sure to follow me for more well-written articles like this.

ย