rust101-fieldlog

04 / 10 · 25 分钟

Journal 拥有这些天

最长远的一天是风口营地,返回的是借来的引用。

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

这一章仍然不新建文件。改 model.rs 和 stats.rs。

Journal 拥有 Vec<DayLog>。days 不要写成 pub。外面如果把向量拿走,日志就空了。只提供 days(),返回 &[DayLog]。这是 ownership:日志拥有数据,别人借走切片。

days 没有 pub。

src/model.rs
/// 一次远征的日志。它拥有每一天,调用者只能借走切片。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Journal {
    pub expedition: String,
    days: Vec<DayLog>,
}

这一章的 add 直接 push。重复日期和口粮留到下一章,那时才有 Result。

src/model.rs
    /// 调用成功以后,这一天的 ownership 属于 `Journal`。
    /// 这一章先直接放进去。校验留到有 `Result` 的下一章。
    pub fn add(&mut self, day: DayLog) {
        self.days.push(day);
    }

动手

找出里程最长的一天

返回切片里的一个引用。空切片返回 None。不要克隆 DayLog。里程相同的时候,留下较晚的那一天。

longest_day 的起点
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。

src/model.rs
/// 天气是有限的几种,不是随意的字符串。
#[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!()。

src/stats.rs
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);
    }
}

检查点

在你的 fieldlog 目录
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。