DEV Community

Cover image for My Tiny Rust Utils, Part 3: save_load.rs
icsboyx
icsboyx

Posted on

My Tiny Rust Utils, Part 3: save_load.rs

Sooner or later, even a small bot needs to remember something.

In my case, that usually means token-like or config-like data.

I do not want to build a whole persistence subsystem for that, but I also do not want save/load code duplicated in random places.

That is where src/utils/save_load.rs comes from.

The basics

The file starts with a few exports and a format enum:

pub use anyhow::{Error, Result, bail};
pub use serde::{Deserialize, Serialize};
pub use serde_json;
pub use toml;

pub static CONFIG_BASE_DIR: &str = ".config";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SaveType {
    JSON,
    YAML,
    #[default]
    TOML,
}
Enter fullscreen mode Exit fullscreen mode

This is a very local design.
It is just trying to answer a smaller question:

"How does this project save and load structured data without repeating itself?"

For that, this is enough.

ConfigManager

The main trait is:

pub trait ConfigManager
where
    Self: Sized + Serialize + for<'de> Deserialize<'de>,
{
    fn save_custom(&self, file_path: PathBuf, dir_path: PathBuf, save_type: SaveType) -> Result<()> {
        let file_path = PathBuf::new()
            .join(dir_path)
            .join(file_path)
            .with_extension(save_type.as_str());
        save_file(&file_path, self, save_type)
    }

    fn load_custom(file_path: PathBuf, dir_path: PathBuf, save_type: SaveType) -> Result<Self> {
        let file_path = PathBuf::new()
            .join(dir_path)
            .join(file_path)
            .with_extension(save_type.as_str());
        load_file(&file_path)
    }

    fn save(&self) -> Result<()> {
        let struct_name = struct_name_from_type::<Self>()?;
        let file_path = PathBuf::new()
            .join(CONFIG_BASE_DIR)
            .join(struct_name)
            .with_extension(SaveType::default().as_str());
        save_file(&file_path, self, SaveType::default())
    }

    fn load() -> Result<Self> {
        let struct_name = struct_name_from_type::<Self>()?;
        let file_path = PathBuf::new()
            .join(CONFIG_BASE_DIR)
            .join(struct_name)
            .with_extension(SaveType::default().as_str());
        load_file(&file_path)
    }
}
Enter fullscreen mode Exit fullscreen mode

What I like here is not sophistication.

It is that the feature code stays short.

In the token flow, I can simply do:

let mut tw_token = TwitchToken::load_or_default();
tw_token.save()?;
Enter fullscreen mode Exit fullscreen mode

That feels about right for a project of this size.

The part that matters most to me

The most important part of this file is not the serialization format.

It is the path validation.

Here is the real function:

fn validate_path(file_path: &Path) -> anyhow::Result<PathBuf> {
    let mut normalized = PathBuf::new();

    for comp in file_path.components() {
        match comp {
            Component::Normal(part) => normalized.push(part),
            Component::CurDir => {}
            Component::Prefix(p) => {
                bail!("Config path should not contain prefix component: {:?}", p);
            }
            Component::RootDir => {
                bail!("Config path should not contain root component: /..",);
            }
            Component::ParentDir => {
                if !normalized.pop() {
                    bail!(
                        "Path traversal detected: config path should not contain parent directory component that goes beyond root dir",
                    )
                }
            }
        }
    }

    if normalized.as_os_str().is_empty() {
        anyhow::bail!("Empty config path");
    }

    Ok(normalized)
}
Enter fullscreen mode Exit fullscreen mode

I trust code more when the rules are visible.

This function makes the rules visible:

  • no absolute paths
  • no prefix components
  • no climbing upward beyond the normalized relative path
  • no empty paths

That is the kind of small defensive behavior I like to centralize.

Not because it is glamorous, but because I do not want to rediscover the same problem later.

Why I prefer this over inline file code

Without a file like this, the same chores start leaking everywhere:

  • picking the file extension
  • creating directories
  • converting values into the chosen format
  • validating paths
  • building consistent error messages

I would rather make those choices once.

This file is basically me admitting that repetitive file IO glue is not something I want to keep solving in five different places.

Moving to Part 4

Once local persistence is under control, the next obvious pain point is external IO.

For this project, that means HTTP.

So the next part is web.rs.

Top comments (0)