04 / 10 · 25 分钟
Journal 拥有这些天
最长远的一天是风口营地,返回的是借来的引用。
你的项目里会动到:src/model.rs · src/stats.rs
这一章仍然不新建文件。改 model.rs 和 stats.rs。
Journal 拥有 Vec<DayLog>。days 不要写成 pub。外面如果把向量拿走,日志就空了。只提供 days(),返回 &[DayLog]。这是 ownership:日志拥有数据,别人借走切片。
days 没有 pub。
/// 一次远征的日志。它拥有每一天,调用者只能借走切片。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Journal {
pub expedition: String,
days: Vec<DayLog>,
}这一章的 add 直接 push。重复日期和口粮留到下一章,那时才有 Result。
/// 调用成功以后,这一天的 ownership 属于 `Journal`。
/// 这一章先直接放进去。校验留到有 `Result` 的下一章。
pub fn add(&mut self, day: DayLog) {
self.days.push(day);
}动手
找出里程最长的一天
返回切片里的一个引用。空切片返回 None。不要克隆 DayLog。里程相同的时候,留下较晚的那一天。
pub fn longest_day(days: &[DayLog]) -> Option<&DayLog> {
todo!("longest_day")
}days.iter().max_by_key(|day| day.distance_m)。引用和传入的切片活得一样久,生命周期可以不写。
用两天数据就能看出来:北口草甸 8400 米,风口营地 12100 米,最长的是风口营地。steepest_day 按 elevation_m 取最大,写法相同,直接抄,不用再做一遍。测试用指针相等确认你返回的是切片里的那一天。
这一章结束时的 model.rs。add 仍然不返回 Result。
/// 天气是有限的几种,不是随意的字符串。
#[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 note: String,
}
// endregion: day_log
// region: journal
/// 一次远征的日志。它拥有每一天,调用者只能借走切片。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Journal {
pub expedition: String,
days: Vec<DayLog>,
}
// endregion: journal
impl Journal {
pub fn new(expedition: impl Into<String>) -> Self {
Self {
expedition: expedition.into(),
days: Vec::new(),
}
}
pub fn days(&self) -> &[DayLog] {
&self.days
}
// region: journal_add
/// 调用成功以后,这一天的 ownership 属于 `Journal`。
/// 这一章先直接放进去。校验留到有 `Result` 的下一章。
pub fn add(&mut self, day: DayLog) {
self.days.push(day);
}
// endregion: journal_add
}
#[cfg(test)]
mod tests {
use super::*;
fn meadow() -> 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,
note: "雾停在谷底。".to_string(),
}
}
#[test]
fn meadow_day_keeps_meters() {
let day = meadow();
assert_eq!(day.distance_m, 8400);
}
#[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_the_day_it_was_given() {
let mut journal = Journal::new("灯塔山脊");
journal.add(meadow());
assert_eq!(journal.days().len(), 1);
assert_eq!(journal.days()[0].title, "北口草甸");
}
}longest_day 可以先留成 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: 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)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Terrain, Weather};
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,
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 wind_gap_is_the_longer_day() {
let mut meadow = day(8400, 140, 320);
meadow.title = "北口草甸".to_string();
meadow.date = "2026-09-18".to_string();
let mut gap = day(12100, 210, 640);
gap.title = "风口营地".to_string();
gap.date = "2026-09-19".to_string();
let days = vec![meadow, gap];
assert_eq!(longest_day(&days).unwrap().title, "风口营地");
}
#[test]
fn empty_slice_has_no_longest_day() {
assert_eq!(longest_day(&[]), None);
assert_eq!(steepest_day(&[]), None);
}
}检查点
cargo test stats::tests::longest_day_borrows_the_later_tie cargo test stats::tests::wind_gap_is_the_longer_day
两条都通过。longest_day 还是 todo!() 时,cargo check 可以通过,测试会 panic。