digest: unify gather/find with composable date_range + date_to_label

Each DigestLevel now carries two date-math fn pointers:
- label_dates: expand an arg into (label, dates covered)
- date_to_label: map any date to this level's label

Parent gather works by expanding its date range then mapping those
dates through the child level's date_to_label to derive child labels.
find_candidates groups journal dates through date_to_label and skips
the current period. This eliminates six per-level functions
(gather_daily/weekly/monthly, find_daily/weekly/monthly_args) and the
three generate_daily/weekly/monthly public entry points in favor of
one generic gather, one generic find_candidates, and one public
generate(store, level_name, arg).
This commit is contained in:
ProofOfConcept 2026-03-03 18:04:21 -05:00
parent 31c1bca7d7
commit a9b90f881e
2 changed files with 110 additions and 126 deletions

View file

@ -12,21 +12,23 @@ use crate::util::memory_subdir;
use chrono::{Datelike, Duration, Local, NaiveDate};
use regex::Regex;
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
// --- Digest level descriptors ---
struct DigestLevel {
name: &'static str, // lowercase, used for filenames and display
title: &'static str, // capitalized, used in prompts
period: &'static str, // "Date", "Week", "Month"
name: &'static str,
title: &'static str,
period: &'static str,
input_title: &'static str,
timeout: u64,
journal_input: bool, // true for daily (journal entries), false for child digests
gather: fn(&Store, &str) -> Result<(String, Vec<(String, String)>), String>,
find_args: fn(&[String], &str) -> Vec<String>,
child_name: Option<&'static str>, // None = journal (leaf), Some = child digest files
/// Expand an arg into (canonical_label, dates covered).
label_dates: fn(&str) -> Result<(String, Vec<String>), String>,
/// Map a YYYY-MM-DD date to this level's label.
date_to_label: fn(&str) -> Option<String>,
}
const DAILY: DigestLevel = DigestLevel {
@ -35,9 +37,9 @@ const DAILY: DigestLevel = DigestLevel {
period: "Date",
input_title: "Journal entries",
timeout: 300,
journal_input: true,
gather: gather_daily,
find_args: find_daily_args,
child_name: None,
label_dates: |date| Ok((date.to_string(), vec![date.to_string()])),
date_to_label: |date| Some(date.to_string()),
};
const WEEKLY: DigestLevel = DigestLevel {
@ -46,9 +48,9 @@ const WEEKLY: DigestLevel = DigestLevel {
period: "Week",
input_title: "Daily digests",
timeout: 300,
journal_input: false,
gather: gather_weekly,
find_args: find_weekly_args,
child_name: Some("daily"),
label_dates: weekly_label_dates,
date_to_label: |date| week_dates(date).ok().map(|(l, _)| l),
};
const MONTHLY: DigestLevel = DigestLevel {
@ -57,9 +59,10 @@ const MONTHLY: DigestLevel = DigestLevel {
period: "Month",
input_title: "Weekly digests",
timeout: 600,
journal_input: false,
gather: gather_monthly,
find_args: find_monthly_args,
child_name: Some("weekly"),
label_dates: monthly_label_dates,
date_to_label: |date| NaiveDate::parse_from_str(date, "%Y-%m-%d")
.ok().map(|d| format!("{}-{:02}", d.year(), d.month())),
};
// --- Input gathering ---
@ -77,79 +80,51 @@ fn load_child_digests(prefix: &str, labels: &[String]) -> Result<Vec<(String, St
Ok(digests)
}
fn find_daily_args(dates: &[String], today: &str) -> Vec<String> {
dates.iter()
.filter(|d| d.as_str() != today)
.cloned()
.collect()
}
/// Unified: gather inputs for any digest level.
fn gather(level: &DigestLevel, store: &Store, arg: &str) -> Result<(String, Vec<(String, String)>), String> {
let (label, dates) = (level.label_dates)(arg)?;
fn find_weekly_args(dates: &[String], today: &str) -> Vec<String> {
let mut weeks: BTreeMap<String, String> = BTreeMap::new();
for date in dates {
if let Ok((wl, _)) = week_dates(date) {
weeks.entry(wl).or_insert_with(|| date.clone());
}
}
weeks.into_values()
.filter(|date| {
week_dates(date).map_or(false, |(_, days)|
days.last().unwrap() < &today.to_string())
})
.collect()
}
fn find_monthly_args(dates: &[String], _today: &str) -> Vec<String> {
let now = Local::now();
let cur = (now.year(), now.month());
let mut months: BTreeSet<(i32, u32)> = BTreeSet::new();
for date in dates {
if let Ok(nd) = NaiveDate::parse_from_str(date, "%Y-%m-%d") {
months.insert((nd.year(), nd.month()));
}
}
months.into_iter()
.filter(|ym| *ym < cur)
.map(|(y, m)| format!("{}-{:02}", y, m))
.collect()
}
fn gather_daily(store: &Store, date: &str) -> Result<(String, Vec<(String, String)>), String> {
let date_re = Regex::new(&format!(
r"^journal\.md#j-{}", regex::escape(date)
)).unwrap();
let mut entries: Vec<_> = store.nodes.values()
.filter(|n| date_re.is_match(&n.key))
.map(|n| {
let label = n.key.strip_prefix("journal.md#j-").unwrap_or(&n.key);
(label.to_string(), n.content.clone())
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok((date.to_string(), entries))
}
fn gather_weekly(_store: &Store, date: &str) -> Result<(String, Vec<(String, String)>), String> {
let (week_label, dates) = week_dates(date)?;
let inputs = load_child_digests("daily", &dates)?;
Ok((week_label, inputs))
}
fn gather_monthly(_store: &Store, arg: &str) -> Result<(String, Vec<(String, String)>), String> {
let (year, month) = if arg.is_empty() {
let now = Local::now();
(now.year(), now.month())
let inputs = if let Some(child_name) = level.child_name {
// Map parent's dates through child's date_to_label → child labels
let child = LEVELS.iter()
.find(|l| l.name == child_name)
.expect("invalid child_name");
let child_labels: Vec<String> = dates.iter()
.filter_map(|d| (child.date_to_label)(d))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
load_child_digests(child_name, &child_labels)?
} else {
let d = NaiveDate::parse_from_str(&format!("{}-01", arg), "%Y-%m-%d")
.map_err(|e| format!("bad month '{}': {} (expected YYYY-MM)", arg, e))?;
(d.year(), d.month())
// Leaf level: scan store for journal entries matching label
let date_re = Regex::new(&format!(
r"^journal\.md#j-{}", regex::escape(&label)
)).unwrap();
let mut entries: Vec<_> = store.nodes.values()
.filter(|n| date_re.is_match(&n.key))
.map(|n| {
let ts = n.key.strip_prefix("journal.md#j-").unwrap_or(&n.key);
(ts.to_string(), n.content.clone())
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries
};
let label = format!("{}-{:02}", year, month);
let child_labels = weeks_in_month(year, month);
let inputs = load_child_digests("weekly", &child_labels)?;
Ok((label, inputs))
}
/// Unified: find candidate labels for auto-generation (past, not yet generated).
fn find_candidates(level: &DigestLevel, dates: &[String], today: &str) -> Vec<String> {
let today_label = (level.date_to_label)(today);
dates.iter()
.filter_map(|d| (level.date_to_label)(d))
.collect::<BTreeSet<_>>()
.into_iter()
.filter(|l| Some(l) != today_label.as_ref())
.collect()
}
// --- Unified generator ---
fn format_inputs(inputs: &[(String, String)], daily: bool) -> String {
@ -184,7 +159,7 @@ fn generate_digest(
.collect::<Vec<_>>()
.join("\n");
let content = format_inputs(inputs, level.journal_input);
let content = format_inputs(inputs, level.child_name.is_none());
let covered = inputs.iter()
.map(|(l, _)| l.as_str())
.collect::<Vec<_>>()
@ -219,24 +194,17 @@ fn generate_digest(
// --- Public API ---
pub fn generate_daily(store: &mut Store, date: &str) -> Result<(), String> {
let (label, inputs) = (DAILY.gather)(store, date)?;
generate_digest(store, &DAILY, &label, &inputs)
}
pub fn generate_weekly(store: &mut Store, date: &str) -> Result<(), String> {
let (label, inputs) = (WEEKLY.gather)(store, date)?;
generate_digest(store, &WEEKLY, &label, &inputs)
}
pub fn generate_monthly(store: &mut Store, month_arg: &str) -> Result<(), String> {
let (label, inputs) = (MONTHLY.gather)(store, month_arg)?;
generate_digest(store, &MONTHLY, &label, &inputs)
pub fn generate(store: &mut Store, level_name: &str, arg: &str) -> Result<(), String> {
let level = LEVELS.iter()
.find(|l| l.name == level_name)
.ok_or_else(|| format!("unknown digest level: {}", level_name))?;
let (label, inputs) = gather(level, store, arg)?;
generate_digest(store, level, &label, &inputs)
}
// --- Date helpers ---
/// Get ISO week label and the 7 dates (Mon-Sun) for the week containing `date`.
/// Week label and 7 dates (Mon-Sun) for the week containing `date`.
fn week_dates(date: &str) -> Result<(String, Vec<String>), String> {
let nd = NaiveDate::parse_from_str(date, "%Y-%m-%d")
.map_err(|e| format!("bad date '{}': {}", date, e))?;
@ -249,16 +217,44 @@ fn week_dates(date: &str) -> Result<(String, Vec<String>), String> {
Ok((week_label, dates))
}
fn weeks_in_month(year: i32, month: u32) -> Vec<String> {
let mut weeks = BTreeSet::new();
/// label_dates for weekly: accepts "YYYY-MM-DD" or "YYYY-WNN".
fn weekly_label_dates(arg: &str) -> Result<(String, Vec<String>), String> {
if !arg.contains('W') {
return week_dates(arg);
}
// Parse "YYYY-WNN"
let (y, w) = arg.split_once("-W")
.ok_or_else(|| format!("bad week label: {}", arg))?;
let year: i32 = y.parse().map_err(|_| format!("bad week year: {}", arg))?;
let week: u32 = w.parse().map_err(|_| format!("bad week number: {}", arg))?;
let monday = NaiveDate::from_isoywd_opt(year, week, chrono::Weekday::Mon)
.ok_or_else(|| format!("invalid week: {}", arg))?;
let dates = (0..7)
.map(|i| (monday + Duration::days(i)).format("%Y-%m-%d").to_string())
.collect();
Ok((arg.to_string(), dates))
}
/// label_dates for monthly: accepts "YYYY-MM" or "YYYY-MM-DD".
fn monthly_label_dates(arg: &str) -> Result<(String, Vec<String>), String> {
let (year, month) = if arg.len() <= 7 {
let d = NaiveDate::parse_from_str(&format!("{}-01", arg), "%Y-%m-%d")
.map_err(|e| format!("bad month '{}': {}", arg, e))?;
(d.year(), d.month())
} else {
let d = NaiveDate::parse_from_str(arg, "%Y-%m-%d")
.map_err(|e| format!("bad date '{}': {}", arg, e))?;
(d.year(), d.month())
};
let label = format!("{}-{:02}", year, month);
let mut dates = Vec::new();
let mut d = 1u32;
while let Some(date) = NaiveDate::from_ymd_opt(year, month, d) {
if date.month() != month { break; }
let iso = date.iso_week();
weeks.insert(format!("{}-W{:02}", iso.year(), iso.week()));
dates.push(date.format("%Y-%m-%d").to_string());
d += 1;
}
weeks.into_iter().collect()
Ok((label, dates))
}
// --- Auto-detect and generate missing digests ---
@ -284,12 +280,12 @@ pub fn digest_auto(store: &mut Store) -> Result<(), String> {
let mut total = 0u32;
for level in LEVELS {
let args = (level.find_args)(&dates, &today);
let candidates = find_candidates(level, &dates, &today);
let mut generated = 0u32;
let mut skipped = 0u32;
for arg in &args {
let (label, inputs) = (level.gather)(store, arg)?;
for arg in &candidates {
let (label, inputs) = gather(level, store, arg)?;
if epi.join(format!("{}-{}.md", level.name, label)).exists() {
skipped += 1;
continue;

View file

@ -765,27 +765,15 @@ fn cmd_digest(args: &[String]) -> Result<(), String> {
let date_arg = args.get(1).map(|s| s.as_str()).unwrap_or("");
match args[0].as_str() {
"daily" => {
let date = if date_arg.is_empty() {
store::format_date(store::now_epoch())
} else {
date_arg.to_string()
};
digest::generate_daily(&mut store, &date)
}
"weekly" => {
let date = if date_arg.is_empty() {
store::format_date(store::now_epoch())
} else {
date_arg.to_string()
};
digest::generate_weekly(&mut store, &date)
}
"monthly" => {
let month = if date_arg.is_empty() { "" } else { date_arg };
digest::generate_monthly(&mut store, month)
}
"auto" => digest::digest_auto(&mut store),
name @ ("daily" | "weekly" | "monthly") => {
let arg = if date_arg.is_empty() {
store::format_date(store::now_epoch())
} else {
date_arg.to_string()
};
digest::generate(&mut store, name, &arg)
}
_ => Err(format!("Unknown digest type: {}. Use: daily, weekly, monthly, auto", args[0])),
}
}