07 / 10 · 20 分钟
用 iterator 算整趟路
简报多出一行均速 3.0 km/h。
你的项目里会动到:src/stats.rs · src/briefing.rs
这一章不新建文件。上一章为了打出 47.2 km,在 briefing.rs 里直接 sum 了。现在把合计挪到 stats.rs,并算出整趟路的配速。lib.rs 不用改。
爬升和移动分钟是同样的形状:iter、map、sum。空切片的和是 0。
pub fn total_distance_m(days: &[DayLog]) -> u32 {
days.iter().map(|day| day.distance_m).sum()
}动手
算整趟路的配速
把总米数和总分钟交给 pace_kmh。不要先算出每一天的配速再平均。休息日没有配速,短的一天也不该和长的一天权重相同。
pub fn average_pace_kmh(days: &[DayLog]) -> Option<f64> {
todo!("average_pace_kmh")
}先写 moving_minutes 和 total_distance_m。总分钟为 0 时,pace_kmh 会返回 None。
然后改 render_terse 和 render_story,改成调用这些函数。距离那一行加上均速。灯塔山脊一共移动 930 分钟,也就是 15 小时 30 分。47.2 km 除以 15.5 小时,显示成 3.0 km/h。average_pace_kmh 可以先留成 todo!()。
use crate::model::DayLog;
// region: pace_kmh
/// 配速,单位 km/h。
///
/// `minutes` 为 0 表示这一天没有移动,返回 `None`。
/// 不要返回 0,0 会被读成「走得很慢」。
pub fn pace_kmh(distance_m: u32, minutes: u32) -> Option<f64> {
if minutes == 0 {
return None;
}
let hours = minutes as f64 / 60.0;
let kilometers = distance_m as f64 / 1000.0;
Some(kilometers / hours)
}
// endregion: pace_kmh
/// 显示用的公里数,精确到 0.1 km。加总和比较仍然用米。
pub fn format_km(distance_m: u32) -> String {
let tenths = (distance_m + 50) / 100;
let whole = tenths / 10;
let tenth = tenths % 10;
format!("{whole}.{tenth}")
}
pub fn format_hours(minutes: u32) -> String {
let hours = minutes / 60;
let rest = minutes % 60;
match (hours, rest) {
(0, rest) => format!("{rest} 分钟"),
(hours, 0) => format!("{hours} 小时"),
(hours, rest) => format!("{hours} 小时 {rest} 分"),
}
}
pub fn format_pace(distance_m: u32, minutes: u32) -> String {
match pace_kmh(distance_m, minutes) {
Some(pace) => format!("{pace:.1} km/h"),
None => "没有移动".to_string(),
}
}
// region: total_distance
pub fn total_distance_m(days: &[DayLog]) -> u32 {
days.iter().map(|day| day.distance_m).sum()
}
// endregion: total_distance
pub fn total_elevation_m(days: &[DayLog]) -> u32 {
days.iter().map(|day| day.elevation_m).sum()
}
pub fn moving_minutes(days: &[DayLog]) -> u32 {
days.iter().map(|day| day.minutes).sum()
}
// region: longest_day
/// 里程最长的一天。返回借来的引用,不克隆 `DayLog`。
/// 里程相同则留下较晚的那一天。
pub fn longest_day(days: &[DayLog]) -> Option<&DayLog> {
days.iter().max_by_key(|day| day.distance_m)
}
// endregion: longest_day
/// 爬升最大的一天。写法和 `longest_day` 相同,用来对照。
pub fn steepest_day(days: &[DayLog]) -> Option<&DayLog> {
days.iter().max_by_key(|day| day.elevation_m)
}
// region: average_pace_kmh
/// 用总米数和总分钟算整趟路的配速。
///
/// 不要先把每一天的配速加起来再平均。休息日没有配速,
/// 短的一天也不该和长的一天权重相同。
pub fn average_pace_kmh(days: &[DayLog]) -> Option<f64> {
let minutes = moving_minutes(days);
let distance_m = total_distance_m(days);
pace_kmh(distance_m, minutes)
}
// endregion: average_pace_kmh
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Terrain, Weather};
use crate::sample::lantern_traverse;
fn day(distance_m: u32, minutes: u32, elevation_m: u32) -> DayLog {
DayLog {
date: format!("2026-09-1{}", distance_m % 8),
title: "测试日".to_string(),
distance_m,
minutes,
elevation_m,
weather: Weather::Clear,
terrain: Terrain::Trail,
meals: 0,
note: String::new(),
}
}
#[test]
fn formats_tenths_of_a_kilometer() {
assert_eq!(format_km(8400), "8.4");
assert_eq!(format_km(12100), "12.1");
assert_eq!(format_km(0), "0.0");
}
#[test]
fn formats_hours_without_empty_parts() {
assert_eq!(format_hours(930), "15 小时 30 分");
assert_eq!(format_hours(120), "2 小时");
assert_eq!(format_hours(40), "40 分钟");
}
#[test]
fn pace_of_the_first_sample_day_is_three_point_six() {
assert_eq!(format_pace(8400, 140), "3.6 km/h");
}
#[test]
fn zero_minutes_is_not_a_pace() {
assert_eq!(pace_kmh(1000, 0), None);
assert_eq!(format_pace(0, 0), "没有移动");
}
#[test]
fn longest_day_borrows_the_later_tie() {
let days = vec![day(1000, 10, 1), day(5000, 10, 1), day(5000, 10, 9)];
let found = longest_day(&days).unwrap();
assert_eq!(found.elevation_m, 9);
assert!(std::ptr::eq(found, &days[2]));
}
#[test]
fn empty_stats_are_zero_and_none() {
assert_eq!(total_distance_m(&[]), 0);
assert_eq!(longest_day(&[]), None);
assert_eq!(steepest_day(&[]), None);
assert_eq!(average_pace_kmh(&[]), None);
}
#[test]
fn lantern_average_pace_displays_as_three() {
let days = lantern_traverse().days().to_vec();
assert_eq!(
format_pace(total_distance_m(&days), moving_minutes(&days)),
"3.0 km/h"
);
assert_eq!(total_elevation_m(&days), 2240);
}
}use crate::model::Journal;
use crate::stats::{
average_pace_kmh, format_hours, format_km, longest_day, moving_minutes, steepest_day,
total_distance_m, total_elevation_m,
};
/// 咖啡够不够。两种结果的字段不一样,所以用 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 pace = average_pace_kmh(days)
.map(|value| format!("{value:.1} km/h"))
.unwrap_or_else(|| "没有移动".to_string());
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} · 均速 {pace}\n口粮剩余 {rations} 顿\n{coffee}\n最长远的一天:{longest}\n最陡的一天:{steepest}\n",
name = journal.expedition,
range = date_range(journal),
count = days.len(),
distance = format_km(total_distance_m(days)),
elevation = total_elevation_m(days),
hours = format_hours(moving_minutes(days)),
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 pace = average_pace_kmh(days)
.map(|value| format!("{value:.1} km/h"))
.unwrap_or_else(|| "没有移动".to_string());
let mut lines = vec![
journal.expedition.clone(),
format!(
"{range},一共 {count} 天。",
range = date_range(journal),
count = days.len()
),
format!(
"你们走了 {distance} km,爬升 {elevation} m,移动 {hours},平均配速 {pace}。",
distance = format_km(total_distance_m(days)),
elevation = total_elevation_m(days),
hours = format_hours(moving_minutes(days)),
),
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_matches_the_traverse() {
let text = render_terse(&lantern_traverse());
assert_eq!(
text,
"\
灯塔山脊
2026-09-18 到 2026-09-22 · 5 天
47.2 km · 爬升 2240 m · 15 小时 30 分 · 均速 3.0 km/h
口粮剩余 2 顿
咖啡只够 2 个早晨,还差 70 g。
最长远的一天:2026-09-19 风口营地(12.1 km)
最陡的一天:2026-09-21 灯塔石(爬升 890 m)
"
);
}
#[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 分 · 均速 3.0 km/h 口粮剩余 2 顿 咖啡只够 2 个早晨,还差 70 g。 最长远的一天:2026-09-19 风口营地(12.1 km) 最陡的一天:2026-09-21 灯塔石(爬升 890 m)
检查点
cargo test sample::tests::sample_totals_are_the_ones_the_story_tells cargo test briefing::tests::terse_brief_matches_the_traverse cargo run
5 天,47.2 km,爬升 2240 m,口粮剩余 2 顿。简报和上面这一段逐字相同,包含均速 3.0 km/h。