08 / 10 · 25 分钟
存成 JSON
写进文件,再读回来,和内存里的日志相等。改过的口粮会被拒绝。
你的项目里会动到:Cargo.toml · src/model.rs · src/error.rs · src/store.rs · src/lib.rs
前七章的 Cargo.toml 没有依赖。这一章才加上 serde。版本锁死,Rust 1.83 也能编。更新的编译器也可以。
[dependencies]
serde = { version = "=1.0.210", features = ["derive"] }
serde_json = "=1.0.128"给已经存在的 Weather、Terrain、DayLog、Journal 加上 Serialize 和 Deserialize。不要新开一个带 derive 的 struct。days 虽然不是 pub,serde 仍然会把它写进 JSON。
derive 加在这一章,不在第 2 章。
/// 一天的记录。距离用米保存,公里只在显示时换算。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DayLog {
/// `YYYY-MM-DD`。
pub date: String,
pub title: String,
pub distance_m: u32,
pub minutes: u32,
pub elevation_m: u32,
pub weather: Weather,
pub terrain: Terrain,
pub meals: u32,
pub note: String,
}FieldError 补上 Io 和 Json 两个变体,Display 里各写一句中文。然后在 Journal 上加 validate。add 已经保证写进去的数据合法;读文件时不能信磁盘,所以再查一遍。
餐数超过库存时返回 RationUnderflow,不改数据。
pub(crate) fn validate(&self) -> Result<(), FieldError> {
if self.expedition.trim().is_empty() {
return Err(FieldError::EmptyExpedition);
}
let mut seen = std::collections::BTreeSet::new();
let mut meals = 0u32;
for day in &self.days {
validate_day(day)?;
if !seen.insert(day.date.clone()) {
return Err(FieldError::DuplicateDate(day.date.clone()));
}
meals = meals.saturating_add(day.meals);
}
if meals > self.ration_meals {
return Err(FieldError::RationUnderflow {
have: self.ration_meals,
need: meals,
});
}
Ok(())
}现在新建 src/store.rs。里面是 save、load、from_json。文件写完,再在 lib.rs 末尾加 pub mod store;。
反序列化成功不等于合法。
pub fn from_json(text: &str) -> Result<Journal, FieldError> {
let journal: Journal =
serde_json::from_str(text).map_err(|err| FieldError::Json(err.to_string()))?;
journal.validate()?;
Ok(journal)
}store 出现在文件已经存在之后。还没有 report。
pub mod briefing;
pub mod error;
pub mod model;
pub mod sample;
pub mod stats;
pub mod store;use std::fmt;
/// 读者能处理的失败。口粮不够不是 panic。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FieldError {
EmptyExpedition,
EmptyTitle,
BadDate(String),
DuplicateDate(String),
RationUnderflow { have: u32, need: u32 },
Io(String),
Json(String),
}
impl fmt::Display for FieldError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FieldError::EmptyExpedition => write!(f, "远征名称不能是空白"),
FieldError::EmptyTitle => write!(f, "这一天的标题不能是空白"),
FieldError::BadDate(date) => write!(f, "日期 {date} 不是 YYYY-MM-DD"),
FieldError::DuplicateDate(date) => write!(f, "日期 {date} 已经记过了"),
FieldError::RationUnderflow { have, need } => {
write!(f, "口粮不够:手头 {have} 顿,需要 {need} 顿")
}
FieldError::Io(message) => write!(f, "读写文件失败:{message}"),
FieldError::Json(message) => write!(f, "JSON 无法读取:{message}"),
}
}
}
impl std::error::Error for FieldError {}原有类型加上 Serialize 和 Deserialize,并多了 validate。
use serde::{Deserialize, Serialize};
use crate::error::FieldError;
/// 天气是有限的几种,不是随意的字符串。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Weather {
Clear,
Fog,
Rain,
Storm,
Snow,
}
/// 脚下是什么。和天气分开,因为雨可以下在山脊上,也可以下在林子里。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Terrain {
Trail,
Ridge,
Scree,
Forest,
Camp,
}
// region: weather_label
/// 简报里的天气用词。`match` 不写 `_`,漏掉一种天气就编不过。
pub fn weather_label(weather: Weather) -> &'static str {
match weather {
Weather::Clear => "晴",
Weather::Fog => "雾",
Weather::Rain => "雨",
Weather::Storm => "暴风雨",
Weather::Snow => "雪",
}
}
// endregion: weather_label
pub fn terrain_label(terrain: Terrain) -> &'static str {
match terrain {
Terrain::Trail => "小径",
Terrain::Ridge => "山脊",
Terrain::Scree => "碎石坡",
Terrain::Forest => "林地",
Terrain::Camp => "营地",
}
}
// region: day_log
/// 一天的记录。距离用米保存,公里只在显示时换算。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DayLog {
/// `YYYY-MM-DD`。
pub date: String,
pub title: String,
pub distance_m: u32,
pub minutes: u32,
pub elevation_m: u32,
pub weather: Weather,
pub terrain: Terrain,
pub meals: u32,
pub note: String,
}
// endregion: day_log
// region: validate_day
pub fn validate_day(day: &DayLog) -> Result<(), FieldError> {
if day.title.trim().is_empty() {
return Err(FieldError::EmptyTitle);
}
if !valid_date(&day.date) {
return Err(FieldError::BadDate(day.date.clone()));
}
Ok(())
}
// endregion: validate_day
fn valid_date(date: &str) -> bool {
let bytes = date.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return false;
}
let Ok(year) = date[0..4].parse::<u16>() else {
return false;
};
let Ok(month) = date[5..7].parse::<u8>() else {
return false;
};
let Ok(day) = date[8..10].parse::<u8>() else {
return false;
};
year >= 1 && (1..=12).contains(&month) && (1..=31).contains(&day)
}
// region: journal
/// 一次远征的日志。它拥有每一天,调用者只能借走切片。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Journal {
pub expedition: String,
pub ration_meals: u32,
pub coffee_grams: u32,
pub coffee_per_day: u32,
days: Vec<DayLog>,
}
// endregion: journal
impl Journal {
pub fn new(
expedition: impl Into<String>,
ration_meals: u32,
coffee_grams: u32,
coffee_per_day: u32,
) -> Result<Self, FieldError> {
let expedition = expedition.into();
if expedition.trim().is_empty() {
return Err(FieldError::EmptyExpedition);
}
Ok(Self {
expedition,
ration_meals,
coffee_grams,
coffee_per_day,
days: Vec::new(),
})
}
pub fn days(&self) -> &[DayLog] {
&self.days
}
pub fn rations_remaining(&self) -> u32 {
let used: u32 = self.days.iter().map(|day| day.meals).sum();
self.ration_meals.saturating_sub(used)
}
// region: journal_add
/// 把一天放进日志。成功之后,这一天的 ownership 属于 `Journal`。
/// 失败时 `days` 保持原样。
pub fn add(&mut self, day: DayLog) -> Result<(), FieldError> {
validate_day(&day)?;
if self.days.iter().any(|existing| existing.date == day.date) {
return Err(FieldError::DuplicateDate(day.date));
}
let remaining = self.rations_remaining();
consume_rations(remaining, day.meals)?;
self.days.push(day);
Ok(())
}
// endregion: journal_add
// region: journal_validate
pub(crate) fn validate(&self) -> Result<(), FieldError> {
if self.expedition.trim().is_empty() {
return Err(FieldError::EmptyExpedition);
}
let mut seen = std::collections::BTreeSet::new();
let mut meals = 0u32;
for day in &self.days {
validate_day(day)?;
if !seen.insert(day.date.clone()) {
return Err(FieldError::DuplicateDate(day.date.clone()));
}
meals = meals.saturating_add(day.meals);
}
if meals > self.ration_meals {
return Err(FieldError::RationUnderflow {
have: self.ration_meals,
need: meals,
});
}
Ok(())
}
// endregion: journal_validate
}
// region: consume_rations
/// 从手头的口粮里扣掉这一天的餐数。
/// 不够时返回 `FieldError::RationUnderflow`,不要 panic,也不要变成 0。
pub fn consume_rations(stock: u32, meals: u32) -> Result<u32, FieldError> {
stock.checked_sub(meals).ok_or(FieldError::RationUnderflow {
have: stock,
need: meals,
})
}
// endregion: consume_rations
#[cfg(test)]
mod tests {
use super::*;
fn sample_day() -> DayLog {
DayLog {
date: "2026-09-18".to_string(),
title: "北口草甸".to_string(),
distance_m: 8400,
minutes: 140,
elevation_m: 320,
weather: Weather::Clear,
terrain: Terrain::Trail,
meals: 3,
note: "雾停在谷底。".to_string(),
}
}
#[test]
fn rejects_blank_title_and_bad_dates() {
let mut day = sample_day();
day.title = " ".to_string();
assert_eq!(validate_day(&day), Err(FieldError::EmptyTitle));
day.title = "北口".to_string();
day.date = "18-09-2026".to_string();
assert!(matches!(validate_day(&day), Err(FieldError::BadDate(_))));
day.date = "2026-13-01".to_string();
assert!(matches!(validate_day(&day), Err(FieldError::BadDate(_))));
}
#[test]
fn weather_labels_cover_every_variant() {
assert_eq!(weather_label(Weather::Clear), "晴");
assert_eq!(weather_label(Weather::Fog), "雾");
assert_eq!(weather_label(Weather::Rain), "雨");
assert_eq!(weather_label(Weather::Storm), "暴风雨");
assert_eq!(weather_label(Weather::Snow), "雪");
}
#[test]
fn journal_owns_days_and_refuses_duplicates_and_hunger() {
let mut journal = Journal::new("灯塔山脊", 4, 80, 30).unwrap();
journal.add(sample_day()).unwrap();
journal.validate().unwrap();
assert_eq!(journal.days().len(), 1);
assert_eq!(journal.rations_remaining(), 1);
let duplicate = journal.add(sample_day());
assert!(matches!(duplicate, Err(FieldError::DuplicateDate(_))));
let mut hungry = sample_day();
hungry.date = "2026-09-19".to_string();
hungry.meals = 2;
let err = journal.add(hungry).unwrap_err();
assert_eq!(err, FieldError::RationUnderflow { have: 1, need: 2 });
assert_eq!(journal.days().len(), 1);
}
#[test]
fn consume_rations_subtracts_or_explains() {
assert_eq!(consume_rations(5, 3), Ok(2));
assert_eq!(consume_rations(0, 0), Ok(0));
assert_eq!(
consume_rations(1, 2),
Err(FieldError::RationUnderflow { have: 1, need: 2 })
);
}
#[test]
fn blank_expedition_name_is_rejected() {
assert_eq!(
Journal::new(" ", 0, 0, 0).unwrap_err(),
FieldError::EmptyExpedition
);
}
}use std::fs;
use std::path::Path;
use crate::error::FieldError;
use crate::model::Journal;
pub fn save(journal: &Journal, path: &Path) -> Result<(), FieldError> {
let text =
serde_json::to_string_pretty(journal).map_err(|err| FieldError::Json(err.to_string()))?;
fs::write(path, text + "\n").map_err(|err| FieldError::Io(err.to_string()))
}
/// 磁盘上的 JSON 不算数,读进来之后再走一遍不变量。
pub fn load(path: &Path) -> Result<Journal, FieldError> {
let text = fs::read_to_string(path).map_err(|err| FieldError::Io(err.to_string()))?;
from_json(&text)
}
// region: from_json
pub fn from_json(text: &str) -> Result<Journal, FieldError> {
let journal: Journal =
serde_json::from_str(text).map_err(|err| FieldError::Json(err.to_string()))?;
journal.validate()?;
Ok(journal)
}
// endregion: from_json
#[cfg(test)]
mod tests {
use super::*;
use crate::sample::lantern_traverse;
#[test]
fn roundtrip_keeps_the_journal() {
let dir = std::env::temp_dir().join(format!("fieldlog-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("journal.json");
let journal = lantern_traverse();
save(&journal, &path).unwrap();
let loaded = load(&path).unwrap();
assert_eq!(loaded, journal);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn tampered_distance_and_hunger_are_rejected() {
let mut value = serde_json::to_value(lantern_traverse()).unwrap();
value["days"][0]["distance_m"] = serde_json::json!("many");
let err = from_json(&value.to_string()).unwrap_err();
assert!(matches!(err, FieldError::Json(_)));
let mut hungry = serde_json::to_value(lantern_traverse()).unwrap();
hungry["ration_meals"] = serde_json::json!(1);
let err = from_json(&hungry.to_string()).unwrap_err();
assert!(matches!(err, FieldError::RationUnderflow { .. }));
}
#[test]
fn missing_file_is_an_io_error() {
let err = load(Path::new("/tmp/fieldlog-does-not-exist-7115.json")).unwrap_err();
assert!(matches!(err, FieldError::Io(_)));
}
}检查点
cargo test store::tests
往返之后日志相等。把 distance_m 改成字符串会得到 Json 错误。把 ration_meals 改成 1 会得到 RationUnderflow。缺文件得到 Io。