rust101-fieldlog

08 / 10 · 25 分钟

存成 JSON

写进文件,再读回来,和内存里的日志相等。改过的口粮会被拒绝。

你的项目里会动到:Cargo.toml · src/model.rs · src/error.rs · src/store.rs · src/lib.rs

前七章的 Cargo.toml 没有依赖。这一章才加上 serde。版本锁死,Rust 1.83 也能编。更新的编译器也可以。

Cargo.toml
[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 章。

src/model.rs
/// 一天的记录。距离用米保存,公里只在显示时换算。
#[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,不改数据。

src/model.rs
    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;。

反序列化成功不等于合法。

src/store.rs
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。

src/lib.rs
pub mod briefing;
pub mod error;
pub mod model;
pub mod sample;
pub mod stats;
pub mod store;
src/error.rs
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。

src/model.rs
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
        );
    }
}
src/store.rs
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(_)));
    }
}

检查点

在你的 fieldlog 目录
cargo test store::tests

往返之后日志相等。把 distance_m 改成字符串会得到 Json 错误。把 ration_meals 改成 1 会得到 RationUnderflow。缺文件得到 Io。