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,
}
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)
}
}
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()?;
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)
}
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)