digest: add gather/find_args methods, collapse digest_auto to loop

DigestLevel gains two methods:
- gather(): returns (label, inputs) for a given arg — daily reads
  journal entries, weekly/monthly compute child labels and load files
- find_args(): returns candidate args from journal dates for auto-
  detection, handling per-level completeness checks

Public generate_daily/weekly/monthly become two-liners: gather + generate.
digest_auto collapses from three near-identical phases into a single
loop over LEVELS.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-03-03 17:42:50 -05:00
parent 796c72fb25
commit b083cc433c

View file

@ -179,23 +179,6 @@ Read all the weekly digests and synthesize the month's story.
// --- Input gathering --- // --- Input gathering ---
/// Collect journal entries for a given date from the store.
fn daily_inputs(store: &Store, date: &str) -> Vec<(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));
entries
}
/// Load child digest files from the episodic directory. /// Load child digest files from the episodic directory.
fn load_child_digests(prefix: &str, labels: &[String]) -> Result<Vec<(String, String)>, String> { fn load_child_digests(prefix: &str, labels: &[String]) -> Result<Vec<(String, String)>, String> {
let dir = memory_subdir("episodic")?; let dir = memory_subdir("episodic")?;
@ -209,6 +192,97 @@ fn load_child_digests(prefix: &str, labels: &[String]) -> Result<Vec<(String, St
Ok(digests) Ok(digests)
} }
impl DigestLevel {
/// Find candidate args from journal dates for auto-detection.
/// Returns args suitable for passing to gather().
fn find_args(&self, dates: &[String], today: &str) -> Vec<String> {
match self.child_prefix {
None => {
// Daily: each date is a candidate, skip today
dates.iter()
.filter(|d| d.as_str() != today)
.cloned()
.collect()
}
Some("daily") => {
// Weekly: group dates by week, return one date per complete week
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()
}
Some(_) => {
// Monthly: group dates by month, return labels for past months
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()
}
}
}
/// Gather inputs for this digest level. Returns (label, inputs).
/// For daily: arg is a date, gathers journal entries from store.
/// For weekly: arg is any date in the week, computes week label.
/// For monthly: arg is "YYYY-MM" (or empty for current month).
fn gather(&self, store: &Store, arg: &str) -> Result<(String, Vec<(String, String)>), String> {
match self.child_prefix {
None => {
// Daily: gather journal entries for this date
let date_re = Regex::new(&format!(
r"^journal\.md#j-{}", regex::escape(arg)
)).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((arg.to_string(), entries))
}
Some("daily") => {
// Weekly: compute week from date, load daily digests
let (week_label, dates) = week_dates(arg)?;
let inputs = load_child_digests("daily", &dates)?;
Ok((week_label, inputs))
}
Some(prefix) => {
// Monthly: parse month arg, load weekly digests
let (year, month) = if arg.is_empty() {
let now = Local::now();
(now.year(), now.month())
} 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())
};
let label = format!("{}-{:02}", year, month);
let child_labels = weeks_in_month(year, month);
let inputs = load_child_digests(prefix, &child_labels)?;
Ok((label, inputs))
}
}
}
}
// --- Unified generator --- // --- Unified generator ---
fn format_inputs(inputs: &[(String, String)], daily: bool) -> String { fn format_inputs(inputs: &[(String, String)], daily: bool) -> String {
@ -280,28 +354,17 @@ fn generate_digest(
// --- Public API --- // --- Public API ---
pub fn generate_daily(store: &mut Store, date: &str) -> Result<(), String> { pub fn generate_daily(store: &mut Store, date: &str) -> Result<(), String> {
let inputs = daily_inputs(store, date); let (label, inputs) = DAILY.gather(store, date)?;
generate_digest(store, &DAILY, date, &inputs) generate_digest(store, &DAILY, &label, &inputs)
} }
pub fn generate_weekly(store: &mut Store, date: &str) -> Result<(), String> { pub fn generate_weekly(store: &mut Store, date: &str) -> Result<(), String> {
let (week_label, dates) = week_dates(date)?; let (label, inputs) = WEEKLY.gather(store, date)?;
let inputs = load_child_digests("daily", &dates)?; generate_digest(store, &WEEKLY, &label, &inputs)
generate_digest(store, &WEEKLY, &week_label, &inputs)
} }
pub fn generate_monthly(store: &mut Store, month_arg: &str) -> Result<(), String> { pub fn generate_monthly(store: &mut Store, month_arg: &str) -> Result<(), String> {
let (year, month) = if month_arg.is_empty() { let (label, inputs) = MONTHLY.gather(store, month_arg)?;
let now = Local::now();
(now.year(), now.month())
} else {
let d = NaiveDate::parse_from_str(&format!("{}-01", month_arg), "%Y-%m-%d")
.map_err(|e| format!("bad month '{}': {} (expected YYYY-MM)", month_arg, e))?;
(d.year(), d.month())
};
let label = format!("{}-{:02}", year, month);
let week_labels = weeks_in_month(year, month);
let inputs = load_child_digests("weekly", &week_labels)?;
generate_digest(store, &MONTHLY, &label, &inputs) generate_digest(store, &MONTHLY, &label, &inputs)
} }
@ -334,94 +397,47 @@ fn weeks_in_month(year: i32, month: u32) -> Vec<String> {
// --- Auto-detect and generate missing digests --- // --- Auto-detect and generate missing digests ---
const LEVELS: &[&DigestLevel] = &[&DAILY, &WEEKLY, &MONTHLY];
pub fn digest_auto(store: &mut Store) -> Result<(), String> { pub fn digest_auto(store: &mut Store) -> Result<(), String> {
let now = Local::now(); let today = Local::now().format("%Y-%m-%d").to_string();
let today = now.format("%Y-%m-%d").to_string();
let epi = memory_subdir("episodic")?; let epi = memory_subdir("episodic")?;
// Phase 1: daily — find dates with journal entries but no digest // Collect all dates with journal entries
let date_re = Regex::new(r"^\d{4}-\d{2}-\d{2}").unwrap(); let date_re = Regex::new(r"^\d{4}-\d{2}-\d{2}").unwrap();
let mut dates: BTreeSet<String> = BTreeSet::new(); let dates: Vec<String> = store.nodes.keys()
for key in store.nodes.keys() { .filter_map(|key| {
if let Some(rest) = key.strip_prefix("journal.md#j-") { key.strip_prefix("journal.md#j-")
if rest.len() >= 10 && date_re.is_match(rest) { .filter(|rest| rest.len() >= 10 && date_re.is_match(rest))
dates.insert(rest[..10].to_string()); .map(|rest| rest[..10].to_string())
} })
} .collect::<BTreeSet<_>>()
} .into_iter()
.collect();
let mut daily_done: Vec<String> = Vec::new(); let mut total = 0u32;
let mut stats = [0u32; 6]; // [daily_gen, daily_skip, weekly_gen, weekly_skip, monthly_gen, monthly_skip]
for date in &dates { for level in LEVELS {
if date == &today { continue; } let args = level.find_args(&dates, &today);
if epi.join(format!("daily-{}.md", date)).exists() { let mut generated = 0u32;
stats[1] += 1; let mut skipped = 0u32;
daily_done.push(date.clone());
for arg in &args {
let (label, inputs) = level.gather(store, arg)?;
if epi.join(format!("{}-{}.md", level.name, label)).exists() {
skipped += 1;
continue; continue;
} }
println!("[auto] Missing daily digest for {}", date); if inputs.is_empty() { continue; }
generate_daily(store, date)?; println!("[auto] Missing {} digest for {}", level.name, label);
stats[0] += 1; generate_digest(store, level, &label, &inputs)?;
daily_done.push(date.clone()); generated += 1;
}
println!("[auto] Daily: {} generated, {} existed", stats[0], stats[1]);
// Phase 2: weekly — group dates into weeks, generate if week is complete
let mut weeks: BTreeMap<String, Vec<String>> = BTreeMap::new();
for date in &daily_done {
if let Ok((wl, _)) = week_dates(date) {
weeks.entry(wl).or_default().push(date.clone());
}
} }
let mut weekly_done: Vec<String> = Vec::new(); println!("[auto] {}: {} generated, {} existed", level.name, generated, skipped);
for (week_label, example_dates) in &weeks { total += generated;
if let Ok((_, days)) = week_dates(example_dates.first().unwrap()) {
if days.last().unwrap() >= &today { continue; }
}
if epi.join(format!("weekly-{}.md", week_label)).exists() {
stats[3] += 1;
weekly_done.push(week_label.clone());
continue;
}
if !example_dates.iter().any(|d| epi.join(format!("daily-{}.md", d)).exists()) {
continue;
}
println!("[auto] Missing weekly digest for {}", week_label);
generate_weekly(store, example_dates.first().unwrap())?;
stats[2] += 1;
weekly_done.push(week_label.clone());
}
println!("[auto] Weekly: {} generated, {} existed", stats[2], stats[3]);
// Phase 3: monthly — group dates into months, generate if month is past
let cur_month = (now.year(), now.month());
let mut months: BTreeSet<(i32, u32)> = BTreeSet::new();
for date in &daily_done {
if let Ok(nd) = NaiveDate::parse_from_str(date, "%Y-%m-%d") {
months.insert((nd.year(), nd.month()));
}
} }
for (y, m) in &months {
if (*y, *m) >= cur_month { continue; }
let label = format!("{}-{:02}", y, m);
if epi.join(format!("monthly-{}.md", label)).exists() {
stats[5] += 1;
continue;
}
let wl = weeks_in_month(*y, *m);
if !wl.iter().any(|w| epi.join(format!("weekly-{}.md", w)).exists()) {
continue;
}
println!("[auto] Missing monthly digest for {}", label);
generate_monthly(store, &label)?;
stats[4] += 1;
}
println!("[auto] Monthly: {} generated, {} existed", stats[4], stats[5]);
let total = stats[0] + stats[2] + stats[4];
if total == 0 { if total == 0 {
println!("[auto] All digests up to date."); println!("[auto] All digests up to date.");
} else { } else {