rust101-fieldlog

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 写完再加。

src/lib.rs
pub mod sample;

同一本日志可以有两种写法。trait Briefing 只规定一件事:能根据 &Journal 写出一段 String。TerseBrief 和 NarrativeBrief 各自 impl。main 选择其中一个。

写在新建的 src/briefing.rs 里。

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,带上完整早晨数,以及还差的克数。

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

src/main.rs
use fieldlog::briefing::{Briefing, TerseBrief};
use fieldlog::sample::lantern_traverse;

fn main() {
    let journal = lantern_traverse();
    print!("{}", TerseBrief.render(&journal));
}

五个模块,没有 store,也没有 report。

src/lib.rs
pub mod briefing;
pub mod error;
pub mod model;
pub mod sample;
pub mod stats;

Journal 多了 coffee_grams 和 coffee_per_day。

src/model.rs
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
        );
    }
}
src/sample.rs
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!()。这一版简报还没有均速。

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

检查点

在你的 fieldlog 目录
cargo test briefing::tests::lantern_coffee_runs_out_after_two_mornings
cargo run

测试得到 Short { last_full_mornings: 2, grams_short: 70 }。cargo run 的输出和上面那一段一致,并且不含「均速」。