Why do we need configuration? Creating and handling configuration files in Rust π
Zofia
Posted on November 8, 2022
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:
- Edit the program yourself, which could take a lot of time
- 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.
Posted on November 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
January 14, 2024