06 / 10 · 25 分钟
打出第一份简报
cargo run 打出咖啡只够 2 个早晨,还差 70 g。
你的项目里会动到:src/model.rs · src/sample.rs · src/briefing.rs · src/main.rs · src/lib.rs
这一章新建两个文件,都是先写文件,再改 lib.rs。先给已经存在的 Journal 加上 coffee_grams 和 coffee_per_day,new 多收这两个参数。然后写 sample.rs。
sample.rs 只负责灯塔山脊这五天:北口草甸、风口营地、鞍部避雨、灯塔石、南坡出口。口粮 16 顿,咖啡 80 g,每天 30 g。它调用 Journal::add,所以不合法的样例在这里就会失败。
sample.rs 写完再加。
pub mod sample;同一本日志可以有两种写法。trait Briefing 只规定一件事:能根据 &Journal 写出一段 String。TerseBrief 和 NarrativeBrief 各自 impl。main 选择其中一个。
写在新建的 src/briefing.rs 里。
/// 同一本日志可以有不同的写法。trait 规定「能写成一段文字」。
pub trait Briefing {
fn render(&self, journal: &Journal) -> String;
}
pub struct TerseBrief;
pub struct NarrativeBrief;
impl Briefing for TerseBrief {
fn render(&self, journal: &Journal) -> String {
render_terse(journal)
}
}
impl Briefing for NarrativeBrief {
fn render(&self, journal: &Journal) -> String {
render_story(journal)
}
}咖啡够不够不是 bool。「够」的时候要知道盖住了几天,「不够」的时候要知道还差多少克。两种结果的字段不一样,用 enum。
动手
估算咖啡还够几个早晨
per_day 为 0,或者计划天数为 0,返回 Enough { days_covered: days_planned }。克数盖得住计划时也返回 Enough,days_covered 用计划天数,不要把多出来的克数算成计划外的一天。否则返回 Short,带上完整早晨数,以及还差的克数。
pub fn coffee_outlook(grams: u32, per_day: u32, days_planned: u32) -> CoffeeOutlook {
todo!("coffee_outlook")
}完整早晨数是 grams / per_day。还差的克数是 per_day * days_planned - grams。只有克数不够才进这个分支。
句子已经写好,在 coffee_sentence 里。80 g、每天 30 g、5 天,会变成「咖啡只够 2 个早晨,还差 70 g。」briefing.rs 写完之后,再加 pub mod briefing;。
然后改 main.rs:删掉对 banner 的调用,打印 TerseBrief。lib.rs 里的 banner 函数也可以删。这一章还不用 clap,cargo run 不带参数。
这一章结束时的 main.rs。
use fieldlog::briefing::{Briefing, TerseBrief};
use fieldlog::sample::lantern_traverse;
fn main() {
let journal = lantern_traverse();
print!("{}", TerseBrief.render(&journal));
}五个模块,没有 store,也没有 report。
pub mod briefing;
pub mod error;
pub mod model;
pub mod sample;
pub mod stats;Journal 多了 coffee_grams 和 coffee_per_day。
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,
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: 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();
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 crate::model::{DayLog, Journal, Terrain, Weather};
/// 灯塔山脊,2026-09-18 到 2026-09-22。
///
/// 口粮出发时 16 顿,五天吃掉 14 顿。咖啡 80 g,按每天 30 g,只够两个早晨。
pub fn lantern_traverse() -> Journal {
let mut journal = Journal::new("灯塔山脊", 16, 80, 30).expect("样例名称有效");
for day in days() {
journal.add(day).expect("样例满足不变量");
}
journal
}
fn days() -> [DayLog; 5] {
[
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(),
},
DayLog {
date: "2026-09-19".to_string(),
title: "风口营地".to_string(),
distance_m: 12100,
minutes: 210,
elevation_m: 640,
weather: Weather::Fog,
terrain: Terrain::Ridge,
meals: 3,
note: "铁索在风里响,下一面什么都看不见。".to_string(),
},
DayLog {
date: "2026-09-20".to_string(),
title: "鞍部避雨".to_string(),
distance_m: 6500,
minutes: 180,
elevation_m: 210,
weather: Weather::Storm,
terrain: Terrain::Scree,
meals: 2,
note: "雷在南面滚了四十分钟。炉头借给了旁边的两人,晚饭因此少了一顿热的。".to_string(),
},
DayLog {
date: "2026-09-21".to_string(),
title: "灯塔石".to_string(),
distance_m: 11000,
minutes: 240,
elevation_m: 890,
weather: Weather::Rain,
terrain: Terrain::Ridge,
meals: 3,
note: "石头上有一圈旧漆,像一盏没有点着的灯。".to_string(),
},
DayLog {
date: "2026-09-22".to_string(),
title: "南坡出口".to_string(),
distance_m: 9200,
minutes: 160,
elevation_m: 180,
weather: Weather::Clear,
terrain: Terrain::Forest,
meals: 3,
note: "出林子的时候咖啡罐是空的,溪水是甜的。".to_string(),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stats::format_km;
#[test]
fn sample_totals_are_the_ones_the_story_tells() {
let journal = lantern_traverse();
let days = journal.days();
let distance_m: u32 = days.iter().map(|day| day.distance_m).sum();
let elevation_m: u32 = days.iter().map(|day| day.elevation_m).sum();
assert_eq!(days.len(), 5);
assert_eq!(format_km(distance_m), "47.2");
assert_eq!(elevation_m, 2240);
assert_eq!(journal.rations_remaining(), 2);
}
#[test]
fn sample_days_are_in_order() {
let journal = lantern_traverse();
let titles: Vec<_> = journal
.days()
.iter()
.map(|day| day.title.as_str())
.collect();
assert_eq!(
titles,
vec!["北口草甸", "风口营地", "鞍部避雨", "灯塔石", "南坡出口"]
);
}
}coffee_outlook 可以先留成 todo!()。这一版简报还没有均速。
use crate::model::Journal;
use crate::stats::{format_hours, format_km, longest_day, steepest_day};
/// 咖啡够不够。两种结果的字段不一样,所以用 enum,不用 `bool`。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoffeeOutlook {
/// `days_covered` 是计划天数里能喝到的早晨数。
Enough { days_covered: u32 },
Short {
last_full_mornings: u32,
grams_short: u32,
},
}
// region: coffee_outlook
/// 按每天固定克数估算咖啡。
///
/// `per_day` 为 0,或者计划天数为 0,视作够用。
pub fn coffee_outlook(grams: u32, per_day: u32, days_planned: u32) -> CoffeeOutlook {
if days_planned == 0 || per_day == 0 {
return CoffeeOutlook::Enough {
days_covered: days_planned,
};
}
let covered = grams / per_day;
if covered >= days_planned {
CoffeeOutlook::Enough {
days_covered: days_planned,
}
} else {
let needed = per_day.saturating_mul(days_planned);
CoffeeOutlook::Short {
last_full_mornings: covered,
grams_short: needed.saturating_sub(grams),
}
}
}
// endregion: coffee_outlook
pub fn coffee_sentence(grams: u32, per_day: u32, days_planned: u32) -> String {
if days_planned == 0 {
return "还没有出发,咖啡原封不动。".to_string();
}
match coffee_outlook(grams, per_day, days_planned) {
CoffeeOutlook::Enough { .. } if per_day == 0 => "这趟路没有把咖啡算进口粮。".to_string(),
CoffeeOutlook::Enough { days_covered } => format!("咖啡够 {days_covered} 个早晨。"),
CoffeeOutlook::Short {
last_full_mornings,
grams_short,
} => format!("咖啡只够 {last_full_mornings} 个早晨,还差 {grams_short} g。"),
}
}
// region: briefing_trait
/// 同一本日志可以有不同的写法。trait 规定「能写成一段文字」。
pub trait Briefing {
fn render(&self, journal: &Journal) -> String;
}
pub struct TerseBrief;
pub struct NarrativeBrief;
impl Briefing for TerseBrief {
fn render(&self, journal: &Journal) -> String {
render_terse(journal)
}
}
impl Briefing for NarrativeBrief {
fn render(&self, journal: &Journal) -> String {
render_story(journal)
}
}
// endregion: briefing_trait
pub fn render_with(briefing: &impl Briefing, journal: &Journal) -> String {
briefing.render(journal)
}
pub fn render_terse(journal: &Journal) -> String {
let days = journal.days();
let distance_m: u32 = days.iter().map(|day| day.distance_m).sum();
let elevation_m: u32 = days.iter().map(|day| day.elevation_m).sum();
let minutes: u32 = days.iter().map(|day| day.minutes).sum();
let longest = longest_day(days)
.map(|day| {
format!(
"{} {}({} km)",
day.date,
day.title,
format_km(day.distance_m)
)
})
.unwrap_or_else(|| "没有记录".to_string());
let steepest = steepest_day(days)
.map(|day| format!("{} {}(爬升 {} m)", day.date, day.title, day.elevation_m))
.unwrap_or_else(|| "没有记录".to_string());
format!(
"{name}\n{range} · {count} 天\n{distance} km · 爬升 {elevation} m · {hours}\n口粮剩余 {rations} 顿\n{coffee}\n最长远的一天:{longest}\n最陡的一天:{steepest}\n",
name = journal.expedition,
range = date_range(journal),
count = days.len(),
distance = format_km(distance_m),
elevation = elevation_m,
hours = format_hours(minutes),
rations = journal.rations_remaining(),
coffee = coffee_sentence(journal.coffee_grams, journal.coffee_per_day, days.len() as u32),
)
}
pub fn render_story(journal: &Journal) -> String {
let days = journal.days();
let distance_m: u32 = days.iter().map(|day| day.distance_m).sum();
let elevation_m: u32 = days.iter().map(|day| day.elevation_m).sum();
let minutes: u32 = days.iter().map(|day| day.minutes).sum();
let mut lines = vec![
journal.expedition.clone(),
format!(
"{range},一共 {count} 天。",
range = date_range(journal),
count = days.len()
),
format!(
"你们走了 {distance} km,爬升 {elevation} m,移动 {hours}。",
distance = format_km(distance_m),
elevation = elevation_m,
hours = format_hours(minutes),
),
String::new(),
];
if let Some(storm) = days
.iter()
.find(|day| day.weather == crate::model::Weather::Storm)
{
lines.push(format!(
"{date}「{title}」遇上暴风雨,只走了 {distance} km。{note}",
date = storm.date,
title = storm.title,
distance = format_km(storm.distance_m),
note = storm.note
));
}
if let Some(day) = steepest_day(days) {
lines.push(format!(
"最陡的是 {date} 的{title},爬升 {elevation} m。",
date = day.date,
title = day.title,
elevation = day.elevation_m
));
}
lines.push(format!("口粮还剩 {} 顿。", journal.rations_remaining()));
lines.push(coffee_sentence(
journal.coffee_grams,
journal.coffee_per_day,
days.len() as u32,
));
if let Some(last) = days.last() {
lines.push(String::new());
lines.push(last.note.clone());
}
lines.push(String::new());
lines.join("\n")
}
pub fn date_range(journal: &Journal) -> String {
match journal.days() {
[] => "还没有出发".to_string(),
[only] => only.date.clone(),
[first, .., last] => format!("{} 到 {}", first.date, last.date),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sample::lantern_traverse;
#[test]
fn lantern_coffee_runs_out_after_two_mornings() {
assert_eq!(
coffee_outlook(80, 30, 5),
CoffeeOutlook::Short {
last_full_mornings: 2,
grams_short: 70
}
);
assert_eq!(coffee_sentence(80, 30, 5), "咖啡只够 2 个早晨,还差 70 g。");
}
#[test]
fn enough_coffee_does_not_invent_extra_days() {
assert_eq!(
coffee_outlook(160, 30, 5),
CoffeeOutlook::Enough { days_covered: 5 }
);
assert_eq!(
coffee_outlook(0, 0, 5),
CoffeeOutlook::Enough { days_covered: 5 }
);
assert_eq!(coffee_sentence(0, 0, 5), "这趟路没有把咖啡算进口粮。");
}
#[test]
fn terse_brief_includes_distance_and_coffee() {
let text = render_terse(&lantern_traverse());
assert!(text.contains("47.2 km"));
assert!(text.contains("爬升 2240 m"));
assert!(text.contains("咖啡只够 2 个早晨,还差 70 g。"));
assert!(text.contains("风口营地"));
assert!(!text.contains("均速"));
}
#[test]
fn story_keeps_the_storm_and_the_last_note() {
let text = render_story(&lantern_traverse());
assert!(text.contains("遇上暴风雨"));
assert!(text.contains("溪水是甜的"));
assert!(text.contains("还差 70 g"));
}
#[test]
fn trait_object_free_generic_uses_either_voice() {
let journal = lantern_traverse();
assert!(render_with(&TerseBrief, &journal).contains("口粮剩余"));
assert!(render_with(&NarrativeBrief, &journal).contains("你们走了"));
}
#[test]
fn empty_journal_still_renders() {
let journal = Journal::new("空营", 0, 10, 5).unwrap();
let text = render_terse(&journal);
assert!(text.contains("还没有出发"));
assert!(text.contains("还没有出发,咖啡原封不动。"));
}
}灯塔山脊 2026-09-18 到 2026-09-22 · 5 天 47.2 km · 爬升 2240 m · 15 小时 30 分 口粮剩余 2 顿 咖啡只够 2 个早晨,还差 70 g。 最长远的一天:2026-09-19 风口营地(12.1 km) 最陡的一天:2026-09-21 灯塔石(爬升 890 m)
这版简报还没有均速。下一章用 iterator 把合计挪到 stats.rs,再把 3.0 km/h 印上去。
检查点
cargo test briefing::tests::lantern_coffee_runs_out_after_two_mornings cargo run
测试得到 Short { last_full_mornings: 2, grams_short: 70 }。cargo run 的输出和上面那一段一致,并且不含「均速」。