repos

darkfurrow.com-rust

mirror archived upstream

A living almanac of seasons, soil, and the quiet knowledge that used to be common. Rust axum with minijinja and Vite.

agriculturealmanacaxumfolk-knowledgegardeningminijinjarustseasonalvite

9.8 KB · 317 lines · Rust Raw History
  1use anyhow::{Context, Result};
  2use serde::Deserialize;
  3use std::collections::HashMap;
  4use std::path::Path;
  5
  6#[derive(Debug, Clone)]
  7pub struct Frontmatter {
  8    pub meta: HashMap<String, String>,
  9    pub body: String,
 10}
 11
 12#[derive(Debug, Default)]
 13pub struct ListItems {
 14    pub bullets: Vec<String>,
 15    pub prose: Vec<String>,
 16}
 17
 18pub fn parse_frontmatter(text: &str) -> Frontmatter {
 19    if let Some(rest) = text.strip_prefix("---\n") {
 20        if let Some(end) = rest.find("\n---\n") {
 21            let meta_str = &rest[..end];
 22            let body = &rest[end + 5..];
 23            let mut meta = HashMap::new();
 24            for line in meta_str.split('\n') {
 25                if let Some((k, v)) = line.split_once(':') {
 26                    meta.insert(k.trim().to_string(), v.trim().to_string());
 27                }
 28            }
 29            return Frontmatter {
 30                meta,
 31                body: body.trim().to_string(),
 32            };
 33        }
 34    }
 35    Frontmatter {
 36        meta: HashMap::new(),
 37        body: text.to_string(),
 38    }
 39}
 40
 41/// Walk lines, splitting `- ` bullets from free-form prose paragraphs.
 42/// Mirrors python `parse_list_items` exactly.
 43pub fn parse_list_items(body: &str) -> ListItems {
 44    let mut bullets = Vec::new();
 45    let mut prose = Vec::new();
 46    let mut in_prose = false;
 47    let mut current = String::new();
 48
 49    for line in body.split('\n') {
 50        let trimmed = line.trim();
 51        if let Some(rest) = trimmed.strip_prefix("- ") {
 52            if in_prose && !current.is_empty() {
 53                prose.push(std::mem::take(&mut current).trim().to_string());
 54                in_prose = false;
 55            }
 56            bullets.push(rest.to_string());
 57        } else if trimmed.is_empty() {
 58            if in_prose && !current.is_empty() {
 59                prose.push(std::mem::take(&mut current).trim().to_string());
 60                in_prose = false;
 61            }
 62        } else {
 63            in_prose = true;
 64            if !current.is_empty() {
 65                current.push(' ');
 66            }
 67            current.push_str(trimmed);
 68        }
 69    }
 70
 71    if in_prose && !current.is_empty() {
 72        prose.push(current.trim().to_string());
 73    }
 74
 75    ListItems { bullets, prose }
 76}
 77
 78/// Parse a body that contains multiple named bullet lists, each introduced by
 79/// a `## name` heading. Order is preserved so callers can render sections in
 80/// the same sequence the file declared them.
 81pub fn parse_named_lists(body: &str) -> Vec<(String, Vec<String>)> {
 82    let mut sections: Vec<(String, Vec<String>)> = Vec::new();
 83    let mut current: Option<(String, Vec<String>)> = None;
 84    for line in body.split('\n') {
 85        let trimmed = line.trim();
 86        if let Some(rest) = trimmed.strip_prefix("## ") {
 87            if let Some(prev) = current.take() {
 88                sections.push(prev);
 89            }
 90            current = Some((rest.trim().to_string(), Vec::new()));
 91        } else if let Some(rest) = trimmed.strip_prefix("- ") {
 92            if let Some((_, items)) = current.as_mut() {
 93                items.push(rest.to_string());
 94            }
 95        }
 96    }
 97    if let Some(prev) = current.take() {
 98        sections.push(prev);
 99    }
100    sections
101}
102
103#[derive(Debug, Clone)]
104pub struct Season {
105    pub name: String,
106    pub label: String,
107    pub start: (u32, u32),
108    pub end: (u32, u32),
109    pub note: String,
110}
111
112#[derive(Debug, Clone)]
113pub struct MoonTip {
114    pub lo: f64,
115    pub hi: f64,
116    pub text: String,
117}
118
119#[derive(Deserialize, Debug)]
120pub struct ManifestEntry {
121    pub path: String,
122}
123
124pub struct SiteData {
125    pub files: HashMap<String, String>,
126    pub seasons: Vec<Season>,
127    /// canonical-order map (insertion order preserved) so the nav reads
128    /// winter -> early spring -> ... -> late fall.
129    pub seasons_order: Vec<String>,
130    pub seasons_by_name: HashMap<String, Season>,
131    pub haiku: HashMap<String, Vec<[String; 3]>>,
132    pub moods: HashMap<String, HashMap<String, String>>,
133    pub moon_tips: Vec<MoonTip>,
134}
135
136fn parse_md_date(s: &str) -> Result<(u32, u32)> {
137    let (m, d) = s.split_once('/').context("expected m/d")?;
138    Ok((m.parse()?, d.parse()?))
139}
140
141pub fn load_data(data_dir: &Path) -> Result<SiteData> {
142    let manifest_path = data_dir.join("manifest.json");
143    let manifest_text = std::fs::read_to_string(&manifest_path)
144        .with_context(|| format!("read manifest: {manifest_path:?}"))?;
145    let manifest: Vec<ManifestEntry> = serde_json::from_str(&manifest_text)?;
146
147    let mut files = HashMap::new();
148    for entry in &manifest {
149        let p = data_dir.join(&entry.path);
150        if let Ok(text) = std::fs::read_to_string(&p) {
151            files.insert(entry.path.clone(), text);
152        }
153    }
154
155    let (seasons, seasons_order, seasons_by_name) = load_seasons(data_dir)?;
156    let haiku = load_haiku(data_dir)?;
157    let moods = load_moods(data_dir)?;
158    let moon_tips = load_moon_tips(data_dir)?;
159
160    Ok(SiteData {
161        files,
162        seasons,
163        seasons_order,
164        seasons_by_name,
165        haiku,
166        moods,
167        moon_tips,
168    })
169}
170
171fn load_seasons(
172    data_dir: &Path,
173) -> Result<(Vec<Season>, Vec<String>, HashMap<String, Season>)> {
174    let dir = data_dir.join("seasons");
175    let mut entries: Vec<_> = std::fs::read_dir(&dir)?
176        .filter_map(Result::ok)
177        .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("md"))
178        .collect();
179    entries.sort_by_key(|e| e.file_name());
180
181    let mut seasons: Vec<Season> = Vec::new();
182    for entry in entries {
183        let text = std::fs::read_to_string(entry.path())?;
184        let parsed = parse_frontmatter(&text);
185        let name = parsed
186            .meta
187            .get("name")
188            .cloned()
189            .context("season missing name")?;
190        let label = parsed
191            .meta
192            .get("label")
193            .cloned()
194            .context("season missing label")?;
195        let start = parse_md_date(parsed.meta.get("start").context("season missing start")?)?;
196        let end = parse_md_date(parsed.meta.get("end").context("season missing end")?)?;
197        let note = parsed.body.trim().to_string();
198
199        seasons.push(Season {
200            name: name.clone(),
201            label: label.clone(),
202            start,
203            end,
204            note: note.clone(),
205        });
206
207        if let (Some(sa), Some(ea)) = (parsed.meta.get("start-alt"), parsed.meta.get("end-alt")) {
208            seasons.push(Season {
209                name,
210                label,
211                start: parse_md_date(sa)?,
212                end: parse_md_date(ea)?,
213                note,
214            });
215        }
216    }
217
218    seasons.sort_by_key(|s| (s.start.0, s.start.1));
219
220    let mut seasons_order = Vec::new();
221    let mut seasons_by_name: HashMap<String, Season> = HashMap::new();
222    for s in &seasons {
223        if !seasons_by_name.contains_key(&s.name) {
224            seasons_order.push(s.name.clone());
225            seasons_by_name.insert(s.name.clone(), s.clone());
226        }
227    }
228
229    Ok((seasons, seasons_order, seasons_by_name))
230}
231
232fn load_haiku(data_dir: &Path) -> Result<HashMap<String, Vec<[String; 3]>>> {
233    let dir = data_dir.join("haiku");
234    let mut out: HashMap<String, Vec<[String; 3]>> = HashMap::new();
235    for entry in std::fs::read_dir(&dir)? {
236        let entry = entry?;
237        if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {
238            continue;
239        }
240        let text = std::fs::read_to_string(entry.path())?;
241        let parsed = parse_frontmatter(&text);
242        let season = parsed
243            .meta
244            .get("season")
245            .cloned()
246            .context("haiku missing season")?;
247        let mut poems = Vec::new();
248        for block in parsed.body.split("---") {
249            let lines: Vec<String> = block
250                .trim()
251                .split('\n')
252                .map(|l| l.trim().to_string())
253                .filter(|l| !l.is_empty())
254                .collect();
255            if lines.len() == 3 {
256                poems.push([lines[0].clone(), lines[1].clone(), lines[2].clone()]);
257            }
258        }
259        out.insert(season, poems);
260    }
261    Ok(out)
262}
263
264fn load_moods(data_dir: &Path) -> Result<HashMap<String, HashMap<String, String>>> {
265    let dir = data_dir.join("moods");
266    let mut out: HashMap<String, HashMap<String, String>> = HashMap::new();
267    for entry in std::fs::read_dir(&dir)? {
268        let entry = entry?;
269        if entry.path().extension().and_then(|s| s.to_str()) != Some("md") {
270            continue;
271        }
272        let text = std::fs::read_to_string(entry.path())?;
273        let parsed = parse_frontmatter(&text);
274        let season = parsed
275            .meta
276            .get("season")
277            .cloned()
278            .context("mood missing season")?;
279        let mut by_time = HashMap::new();
280        for line in parsed.body.split('\n') {
281            let line = line.trim();
282            if let Some(rest) = line.strip_prefix("- ") {
283                if let Some((time, mood)) = rest.split_once(':') {
284                    by_time.insert(time.trim().to_string(), mood.trim().to_string());
285                }
286            }
287        }
288        out.insert(season, by_time);
289    }
290    Ok(out)
291}
292
293fn load_moon_tips(data_dir: &Path) -> Result<Vec<MoonTip>> {
294    let path = data_dir.join("moon-tips.md");
295    let text = std::fs::read_to_string(&path)?;
296    let parsed = parse_frontmatter(&text);
297    let mut tips = Vec::new();
298    for line in parsed.body.split('\n') {
299        let line = line.trim();
300        let Some(rest) = line.strip_prefix("- ") else {
301            continue;
302        };
303        let Some((range_str, tip_text)) = rest.split_once(':') else {
304            continue;
305        };
306        let Some((lo, hi)) = range_str.trim().split_once('-') else {
307            continue;
308        };
309        tips.push(MoonTip {
310            lo: lo.parse()?,
311            hi: hi.parse()?,
312            text: tip_text.trim().to_string(),
313        });
314    }
315    Ok(tips)
316}