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。
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 保存之后再加这一行。
pub mod error;回到 model.rs。DayLog 加上 meals。Journal 加上 ration_meals。new 和 add 改成返回 Result。add 的顺序是:先 validate_day,再看日期是否重复,再扣口粮,最后才 push。中间任何一步失败,向量都不变。
? 把错误从 add 返回,push 不会执行。
/// 把一天放进日志。成功之后,这一天的 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)。
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 还在。
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!()。
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
);
}
}检查点
cargo test model::tests::journal_owns_days_and_refuses_duplicates_and_hunger
重复日期和口粮不够都失败,失败后天数不变。consume_rations 还是 todo!() 时,cargo check 通过,这条测试 panic。