rust101-fieldlog

05 / 10 · 25 分钟

口粮不够就返回 Result

错误会写出手头有几顿、需要几顿。days 保持原样。

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

你现在有 model.rs 和 stats.rs。这一章新建 src/error.rs。文件写完,再在 lib.rs 里加 pub mod error;。

panic 留给不该发生的内部错误。口粮不够是路上会发生的事,用 Result 交回去。调用方决定拒绝这一天,还是少吃一顿。不够的时候不要返回 0,否则简报只显示「还剩 0 顿」,看不出是算错了还是真吃完了。

新建 src/error.rs。这一章还没有文件读写,所以没有 Io 和 Json。

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 },
}

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} 顿")
            }
        }
    }
}

impl std::error::Error for FieldError {}

error.rs 保存之后再加这一行。

src/lib.rs
pub mod error;

回到 model.rs。DayLog 加上 meals。Journal 加上 ration_meals。new 和 add 改成返回 Result。add 的顺序是:先 validate_day,再看日期是否重复,再扣口粮,最后才 push。中间任何一步失败,向量都不变。

? 把错误从 add 返回,push 不会执行。

src/model.rs
    /// 把一天放进日志。成功之后,这一天的 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(())
    }

动手

扣掉这一天的餐数

够就返回剩下的顿数。不够就返回 FieldError::RationUnderflow { have, need }。不要 panic,不够时也不要返回 Ok(0)。

consume_rations 的起点
pub fn consume_rations(stock: u32, meals: u32) -> Result<u32, FieldError> {
    todo!("consume_rations")
}

stock.checked_sub(meals) 得到 Option。再用 ok_or 变成 Result。

灯塔山脊出发时 16 顿,五天吃掉 14 顿,还剩 2 顿。这个数字下一章才放进样例。这一章的测试用更小的库存:手头剩 1 顿,再记 2 顿,必须失败,而且日志里仍然只有原来的那一天。

error 加在 model 和 stats 旁边。banner 还在。

src/lib.rs
pub fn banner() -> &'static str {
    "灯塔山脊 · 2026-09-18 到 2026-09-22"
}

pub mod error;
pub mod model;
pub mod stats;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn banner_names_the_traverse() {
        let text = banner();
        assert!(text.contains("灯塔山脊"));
    }
}

consume_rations 可以先留成 todo!()。

src/model.rs
use crate::error::FieldError;

/// 天气是有限的几种,不是随意的字符串。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Weather {
    Clear,
    Fog,
    Rain,
    Storm,
    Snow,
}

/// 脚下是什么。和天气分开,因为雨可以下在山脊上,也可以下在林子里。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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)]
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)]
pub struct Journal {
    pub expedition: String,
    pub ration_meals: u32,
    days: Vec<DayLog>,
}
// endregion: journal

impl Journal {
    pub fn new(expedition: impl Into<String>, ration_meals: u32) -> Result<Self, FieldError> {
        let expedition = expedition.into();
        if expedition.trim().is_empty() {
            return Err(FieldError::EmptyExpedition);
        }
        Ok(Self {
            expedition,
            ration_meals,
            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: 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).unwrap();
        journal.add(sample_day()).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).unwrap_err(),
            FieldError::EmptyExpedition
        );
    }
}

检查点

在你的 fieldlog 目录
cargo test model::tests::journal_owns_days_and_refuses_duplicates_and_hunger

重复日期和口粮不够都失败,失败后天数不变。consume_rations 还是 todo!() 时,cargo check 通过,这条测试 panic。