rust101-fieldlog

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。

src/stats.rs
pub fn total_distance_m(days: &[DayLog]) -> u32 {
    days.iter().map(|day| day.distance_m).sum()
}

动手

算整趟路的配速

把总米数和总分钟交给 pace_kmh。不要先算出每一天的配速再平均。休息日没有配速,短的一天也不该和长的一天权重相同。

average_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!()。

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: 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);
    }
}
src/briefing.rs
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("还没有出发,咖啡原封不动。"));
    }
}
cargo run
灯塔山脊
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)

检查点

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