skip to content
Post Header

Implementing the Config Management for my CLI

This is Part 3 in Building a CLI in Rust Series

In the last post, I mapped out the overall API I want to implement. In this post, I will focus on writing the config parsing for the rest of the project.

Reset

I recently bit the bullet and converted my neovim configuration to use LazyVim. A relatively flexible and customizable Neovim configuration. I was tired of maintaining an editor config I rarely use. This configuration is very familiar to use and I think I won’t have to think about tiny details or plugin management or other things for a long time. I was already procrastinating doing this series so I thought I would be a good test drive to assess the development experience of the new configuration.

The first thing to do was to part ways with the old version of the project. I created a branch called rust-rewrite and deleted everything except the LICENCE.md and .gitignore. then executed cargo init to mark the new beginning.

Config Management

There are several cases that we need to cover dealing with config file. Let’s go over them step by step.

  • Config should be defined in a pre-determined location.
  • If Config file exists, we must read it and return the result.
  • If config file does not exists, we first check if the parent folder is present.
    • We create the folder structure if it doesn’t exist.
  • Finally we create the empty config and return to be used.
    • We could throw an error, But I thought that once the config is created, we can pass to the next step and we can handle the invalid configuration there.

Config Location

The config will reside in the .config/lazydraft folder, similar to the previous version. Full path will contain $HOME variable in it. So we can start by getting the valid HOME environment variable.

Before doing that, I created a type called ConfigResult to define the Success and Error states of config parsing.

type ConfigResult<T> = Result<T, String>;

fn validate_config -> ConfigResult<()> {
    if let Ok(home) = env::var("HOME") {
        let config_path = format!("{}/.config/lazydraft/lazydraft.json", home);
    } else {
        Err(String::from("Home environment variable not set"))
    }

}

After building the config path, we can check that if the file exists or not. If it exists, we can parse the config and return it.

Config Struct

Speaking of config, I mentioned that I want to keep the config as a JSON file. I found that dealing with yaml files are more cumbersome than it needs to be. With JSON, I can be sure about the structure and the format easier. That part could change in the future but I think It will be easy to replace If I want to implement a new solution. The only thing I would need update is to config parsing method. Here is the code for the Config Struct:

#[derive(Serialize, Deserialize)]
struct Config {
    source_dir: String,
    source_asset_dir: String,
    target_dir: String,
    target_asset_dir: String,
    target_asset_prefix: String,
    yaml_asset_prefix: String,
}

I decided to use Serde crate to handle our config file. Config derives Serialize and Deserialize trait. To display the config on the screen, we also need to implement a fmt function for the Config. I also added an utility function to return if any of the config parameter is defined empty. We can use this function to check if we have a valid config or not.

impl fmt::Display for Config {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "\nConfigStruct {{")?;
        writeln!(f, "    source_dir: {}", self.source_dir)?;
        writeln!(f, "    source_asset_dir: {}", self.source_asset_dir)?;
        writeln!(f, "    target_dir: {}", self.target_dir)?;
        writeln!(f, "    target_asset_dir: {}", self.target_asset_dir)?;
        writeln!(f, "    target_asset_prefix: {}", self.target_asset_prefix)?;
        writeln!(f, "    yaml_asset_prefix: {}", self.yaml_asset_prefix)?;
        write!(f, "}}")
    }
}
impl Config {
    // Method to check if any fields are empty
    fn has_empty_fields(&self) -> bool {
        self.source_dir.is_empty()
            || self.source_asset_dir.is_empty()
            || self.target_dir.is_empty()
            || self.target_asset_dir.is_empty()
            || self.target_asset_prefix.is_empty()
            || self.yaml_asset_prefix.is_empty()
    }
}

Config parsing logic

If the config is present, We can open the config file, attach a reader, and read the config file to the Config struct using Serde’s serde_json module.

if fs::metadata(&config_path).is_ok() {
	// Read the JSON structure from the file
	let file = File::open(&config_path)
		.map_err(|err| format!("Failed to open a config file: {}", err))?;

	let reader = BufReader::new(file);

	let config: Config = serde_json::from_reader(reader)
		.map_err(|e| format!("Failed to deserialize JSON: {}", e))?;

	return Ok(config);
}

The next part is to check the existence of parent folder structure. We do this by getting the parent of the file path and checking whether it exists or not. If not, we create the whole folder chain using create_dir_all function.

if let Some(parent) = std::path::Path::new(&config_path).parent() {
	if !parent.exists() {
		if let Err(err) = fs::create_dir_all(parent) {
			return Err(format!("Failed to create directory: {}", err));
		}
	}
}

Final part is to create the empty config file and initializing with an empty config. We create the file using create from the File struct and according to the success, we do the following:

  • Create an empty config struct.
  • Convert it to pretty string and serialize it.
  • Write the content to the empty config file.
  • Return the initialized config.

Here is the final part that executes these steps:

match File::create(&config_path) {
	Ok(mut file) => {
		let empty_config = Config {
			source_dir: String::new(),
			source_asset_dir: String::new(),
			target_dir: String::new(),
			target_asset_dir: String::new(),
			target_asset_prefix: String::new(),
			yaml_asset_prefix: String::new(),
		};

		// Serialize the updated JSON structure
		let serialized_empty_config = match serde_json::to_string_pretty(&empty_config) {
			Ok(content) => content,
			Err(err) => return Err(format!("Failed to serialize JSON: {}", err)),
		};
		file.write_all(serialized_empty_config.as_bytes())
			.map_err(|e| format!("Failed to initialize the config: {}", e))?;

		println!("Config file is created successfully at {}", config_path);

		Ok(empty_config)
	}
	Err(e) => Err(format!("Failed to create config file: {}", e)),
}

Result

We now have a working config object that we can use inside our Commands! I find the exhaustive nature of Rust’s standard library a joy to work with. Writing code and understanding the logic is quite clear. Chaining the functions and using match statements also helps you to define errors out of existence.

Here is the current state of our project:

terminal

And our empty initialized config:

config object

In the next post I will start to implement the commands, starting with list. See you then!


References: