4.5 Reading Its Own Instructions
In a proper workshop there is a sheet on the workbench with settings: where to store the work, what mode to use, what to check first. The craftsman does not take it when they leave
- the sheet is for the tool, not for the craftsman. The tool works without it too. It does everything the way it always has, regardless of what has changed outside. This is usually called reliability. It has another name as well.
What You Need
The path to the task file is currently hard-coded. That works - right up until tasks need
to be stored somewhere else. A Config struct is needed to hold settings, and a
config.toml file to read them from. If the file is absent, defaults apply.
The Build
toml is a crate for reading files in the format of the same name. cargo add toml adds
it to the dependencies. In Cargo.toml:
# Cargo.toml - CHANGED
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
Config is a new module. In src/config/mod.rs:
// src/config/mod.rs - NEW
use crate::error::{TqError, TqResult};
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Deserialize)]
pub struct Config {
pub data_dir: PathBuf,
}
impl Default for Config {
fn default() -> Self {
Config {
data_dir: PathBuf::from("."),
}
}
}
impl Config {
pub fn load(path: &Path) -> TqResult<Config> {
let config = if !path.exists() {
Config::default()
} else {
let content = fs::read_to_string(path).map_err(|e| TqError::Parse(e.to_string()))?;
toml::from_str(&content).map_err(|e| TqError::Parse(e.to_string()))?
};
if !config.data_dir.exists() {
return Err(TqError::Parse(format!(
"data_dir '{}' does not exist; create it with: mkdir -p {}",
config.data_dir.display(),
config.data_dir.display()
)));
}
Ok(config)
}
}
#[derive(Deserialize)] - the same mechanism as for Task, but without Serialize:
configuration is read, not written. toml::from_str reads the file contents and fills the
struct. If the file is absent, Config::default() provides the defaults.
impl Default is a standard Rust trait for default values. It allows writing
Config::default() wherever a config “from thin air” is needed, and declares what that
means explicitly - not through magic constants scattered through the code.
The config.toml file itself lives at the project root, next to Cargo.toml. cargo run
is launched from there too, so Path::new("config.toml") finds it correctly. One line:
data_dir = "."
In src/main.rs the path now comes from the config. Add mod config and
use config::Config, and replace the two opening lines of main:
// src/main.rs - CHANGED
fn main() -> TqResult<()> {
let config = Config::load(Path::new("config.toml"))?;
let path = config.data_dir.join("tasks.json"); // CHANGED
let tasks = persistence::load(&path)?;
// ...
persistence::save(store.all(), &path)?;
One hard-coded path line became two: load the config, build the path from it. Previously
path was a &Path - Path::new returns a reference directly. Now join returns a new
PathBuf, not a reference - which is why &path appears in the calls. The rest of main
does not change.
The Result
Without config.toml - behaviour is unchanged; tq looks for tasks.json in the current
directory. With the file:
data_dir = "/home/user/.tq"
tasks are stored there - provided the directory exists. If not:
Error: Parse("data_dir '/home/user/.tq' does not exist; create it with: mkdir -p /home/user/.tq")
load checks data_dir immediately after parsing the config - the same if !path.exists()
pattern already present in persistence::load. The error does not just report the problem;
it says what to do. The code does not change. Only the sheet on the workbench.
Four tests cover all paths. In src/config/tests.rs:
// src/config/tests.rs - NEW
use super::*;
#[test]
fn config_defaults_to_current_dir() {
let config = Config::default();
assert_eq!(config.data_dir, PathBuf::from("."));
}
#[test]
fn config_load_missing_file_returns_default() {
let path = std::env::temp_dir().join("tq_test_m45_no_config.toml");
let _ = std::fs::remove_file(&path);
let config = Config::load(&path).unwrap();
assert_eq!(config.data_dir, PathBuf::from("."));
}
#[test]
fn config_load_parses_data_dir() {
let path = std::env::temp_dir().join("tq_test_m45_config.toml");
std::fs::write(&path, r#"data_dir = "/tmp""#).unwrap();
let config = Config::load(&path).unwrap();
assert_eq!(config.data_dir, PathBuf::from("/tmp"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn config_load_rejects_missing_data_dir() {
let config_path = std::env::temp_dir().join("tq_test_m45_bad_config.toml");
let missing = "/tmp/tq_nonexistent_xyz";
let _ = std::fs::remove_dir_all(missing);
std::fs::write(&config_path, format!(r#"data_dir = "{}""#, missing)).unwrap();
let result = Config::load(&config_path);
assert!(result.is_err());
let _ = std::fs::remove_file(&config_path);
}
make ci passes.
The complete
tqcode for this chapter is in4-memory/05-reading-its-own-instructions/.
Lore: use - an Alias, Not a Lock
In chapter 4.1 the line use serde::{Deserialize, Serialize} appeared in the code without
explanation, as if there were no other way. In fact use does not grant access to a crate:
any crate in Cargo.toml is reachable by its full path anywhere in the code without a
single use.
use is an alias. It allows writing a short name instead of the full path. In chapter 4.1
the alias was necessary: #[derive(Deserialize)] is a short name in an attribute - without
use, it would need to be #[derive(serde::Deserialize)]. In this chapter toml::from_str
is the full path, used once, and the notation is simple - an alias would be redundant.
The same logic applies to use std::fmt and use std::path::Path - convenience, not
obligation.
Lore: TOML as a Configuration Format
JSON is convenient for exchanging data between programs. For files that a human edits, it
has an awkward property: comments are forbidden. Writing # path to data next to a value
is a syntax error.
TOML was designed specifically for configuration files. Comments are native:
# Where tasks are stored
data_dir = "."
Hierarchy through sections, dates as a first-class type, multi-line strings. A person opens the file and understands it without documentation. That is why most tools in the Rust ecosystem - Cargo itself included - use TOML for configuration.