use crate::provider::{ChatMessage, ContentPart, Provider, Role};
use crate::session::{SessionMeta, SessionStore, StoredMessage};
use chrono::{Datelike, Duration, Local, NaiveDate, Weekday};
use sapphire_workspace::WorkspaceState;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tracing::{info, warn};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum LogKind {
Daily,
Weekly,
Monthly,
Yearly,
}
impl LogKind {
pub fn dir(self) -> &'static str {
match self {
LogKind::Daily => "daily",
LogKind::Weekly => "weekly",
LogKind::Monthly => "monthly",
LogKind::Yearly => "yearly",
}
}
pub fn heading_label(self) -> &'static str {
match self {
LogKind::Daily => "Daily Log",
LogKind::Weekly => "Weekly Log",
LogKind::Monthly => "Monthly Log",
LogKind::Yearly => "Yearly Log",
}
}
}
pub fn log_rel_path(namespace: &str, kind: LogKind, stem: &str) -> PathBuf {
Path::new("memory")
.join(namespace)
.join(kind.dir())
.join(format!("{stem}.md"))
}
pub fn log_abs_path(workspace_dir: &Path, namespace: &str, kind: LogKind, stem: &str) -> PathBuf {
workspace_dir.join(log_rel_path(namespace, kind, stem))
}
pub fn daily_stem(date: NaiveDate) -> String {
date.format("%Y-%m-%d").to_string()
}
pub fn weekly_stem(iso_year: i32, iso_week: u32) -> String {
format!("{iso_year:04}-W{iso_week:02}")
}
pub fn monthly_stem(year: i32, month: u32) -> String {
format!("{year:04}-{month:02}")
}
pub fn yearly_stem(year: i32) -> String {
format!("{year:04}")
}
pub fn days_of_iso_week(iso_year: i32, iso_week: u32) -> Vec<NaiveDate> {
let Some(monday) = NaiveDate::from_isoywd_opt(iso_year, iso_week, Weekday::Mon) else {
return Vec::new();
};
(0..7)
.map(|offset| monday + Duration::days(offset))
.collect()
}
pub fn month_stems_in_year_before(today: NaiveDate) -> Vec<String> {
let year = today.year();
(1..today.month()).map(|m| monthly_stem(year, m)).collect()
}
pub fn existing_yearly_stems(workspace_dir: &Path, namespace: &str) -> Vec<String> {
let dir = workspace_dir.join("memory").join(namespace).join("yearly");
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut out: Vec<String> = entries
.flatten()
.filter_map(|e| {
let path = e.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
return None;
}
path.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string)
})
.collect();
out.sort();
out
}
pub fn days_of_month(year: i32, month: u32) -> Vec<NaiveDate> {
let Some(start) = NaiveDate::from_ymd_opt(year, month, 1) else {
return Vec::new();
};
let mut out = Vec::new();
let mut d = start;
while d.month() == month {
out.push(d);
d += Duration::days(1);
}
out
}
pub fn week_stems_in_month_before(today: NaiveDate) -> Vec<String> {
let current_iso = today.iso_week();
let month = today.month();
let Some(start) = NaiveDate::from_ymd_opt(today.year(), month, 1) else {
return Vec::new();
};
let mut out = Vec::new();
let mut d = start;
while d.month() == month {
if d.weekday() == Weekday::Mon {
let iso = d.iso_week();
if iso.year() != current_iso.year() || iso.week() != current_iso.week() {
out.push(weekly_stem(iso.year(), iso.week()));
}
}
d += Duration::days(1);
}
out
}
pub fn daily_stems_in_current_iso_week_before(today: NaiveDate) -> Vec<String> {
let iso = today.iso_week();
let Some(monday) = NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon) else {
return Vec::new();
};
let yesterday = today - Duration::days(1);
let mut out = Vec::new();
let mut d = monday;
while d < yesterday && d.iso_week() == iso {
out.push(daily_stem(d));
d += Duration::days(1);
}
out
}
pub fn pending_daily_dates<F>(
session_store: &SessionStore,
workspace_dir: &Path,
namespace: &str,
boundary_hour: u8,
room_predicate: F,
) -> Vec<NaiveDate>
where
F: Fn(&crate::session::SessionMeta) -> bool,
{
let today = crate::session::local_date_for_timestamp(Local::now(), boundary_hour);
let mut dates = session_store.all_session_dates_filtered(boundary_hour, room_predicate);
dates.retain(|&date| {
date < today
&& !log_abs_path(workspace_dir, namespace, LogKind::Daily, &daily_stem(date)).exists()
});
dates
}
#[allow(clippy::too_many_arguments)]
pub async fn generate_daily_log<F>(
session_store: &SessionStore,
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
date: NaiveDate,
boundary_hour: u8,
room_predicate: F,
) -> anyhow::Result<bool>
where
F: Fn(&crate::session::SessionMeta) -> bool,
{
let sessions = session_store.sessions_for_day_filtered(date, boundary_hour, room_predicate);
let stem = daily_stem(date);
let has_existing = read_body(workspace_dir, namespace, LogKind::Daily, &stem)
.is_some_and(|b| !b.trim().is_empty());
if sessions.is_empty() && !has_existing {
info!("No sessions found for {date} in namespace '{namespace}', skipping daily log");
return Ok(false);
}
let transcript = if sessions.is_empty() {
String::new()
} else {
format_sessions(&sessions, date)
};
write_log_with_digest(
provider,
ws_state,
workspace_dir,
namespace,
LogKind::Daily,
&stem,
"conversation transcripts",
&transcript,
)
.await?;
Ok(true)
}
pub async fn generate_weekly_log(
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
iso_year: i32,
iso_week: u32,
) -> anyhow::Result<bool> {
let stem = weekly_stem(iso_year, iso_week);
let days = days_of_iso_week(iso_year, iso_week);
let mut sections = Vec::new();
for day in &days {
if let Some(body) = read_body(workspace_dir, namespace, LogKind::Daily, &daily_stem(*day)) {
sections.push(body);
}
}
if sections.is_empty() {
info!(
"No daily logs found for ISO week {stem} in namespace '{namespace}', skipping weekly log"
);
return Ok(false);
}
let input = sections.join("\n\n---\n\n");
let description = format!("daily logs for ISO week {stem}");
write_log_with_digest(
provider,
ws_state,
workspace_dir,
namespace,
LogKind::Weekly,
&stem,
&description,
&input,
)
.await?;
Ok(true)
}
pub async fn generate_monthly_log(
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
year: i32,
month: u32,
) -> anyhow::Result<bool> {
let stem = monthly_stem(year, month);
let days = days_of_month(year, month);
let mut sections = Vec::new();
for day in &days {
if let Some(body) = read_body(workspace_dir, namespace, LogKind::Daily, &daily_stem(*day)) {
sections.push(body);
}
}
if sections.is_empty() {
info!(
"No daily logs found for month {stem} in namespace '{namespace}', skipping monthly log"
);
return Ok(false);
}
let input = sections.join("\n\n---\n\n");
let description = format!("daily logs for {stem}");
write_log_with_digest(
provider,
ws_state,
workspace_dir,
namespace,
LogKind::Monthly,
&stem,
&description,
&input,
)
.await?;
Ok(true)
}
pub async fn generate_yearly_log(
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
year: i32,
) -> anyhow::Result<bool> {
let stem = yearly_stem(year);
let mut sections = Vec::new();
for month in 1..=12 {
let m_stem = monthly_stem(year, month);
if let Some(body) = read_body(workspace_dir, namespace, LogKind::Monthly, &m_stem) {
sections.push(body);
}
}
if sections.is_empty() {
info!(
"No monthly logs found for year {stem} in namespace '{namespace}', skipping yearly log"
);
return Ok(false);
}
let input = sections.join("\n\n---\n\n");
let description = format!("monthly logs for {stem}");
write_log_with_digest(
provider,
ws_state,
workspace_dir,
namespace,
LogKind::Yearly,
&stem,
&description,
&input,
)
.await?;
Ok(true)
}
pub fn read_body(
workspace_dir: &Path,
namespace: &str,
kind: LogKind,
stem: &str,
) -> Option<String> {
let raw = std::fs::read_to_string(log_abs_path(workspace_dir, namespace, kind, stem)).ok()?;
Some(match crate::frontmatter::split(&raw) {
Some((_, body)) => body.trim_start_matches('\n').to_string(),
None => raw,
})
}
pub fn read_digest_top_n(
workspace_dir: &Path,
namespace: &str,
kind: LogKind,
stem: &str,
n: usize,
) -> Option<Vec<String>> {
let raw = std::fs::read_to_string(log_abs_path(workspace_dir, namespace, kind, stem)).ok()?;
let (fm, _) = crate::frontmatter::split(&raw)?;
let mapping = crate::frontmatter::parse_mapping(fm);
let seq = mapping.get("digest")?.as_sequence()?;
Some(
seq.iter()
.filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
.take(n)
.collect(),
)
}
#[allow(clippy::too_many_arguments)]
async fn write_log_with_digest(
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
kind: LogKind,
stem: &str,
input_description: &str,
input_body: &str,
) -> anyhow::Result<()> {
let label = kind.heading_label();
let existing_body =
read_body(workspace_dir, namespace, kind, stem).filter(|b| !b.trim().is_empty());
let has_existing = existing_body.is_some();
let combined_input = match &existing_body {
Some(existing) => format!(
"## Existing draft at `memory/{}/{}/{}.md` — preserve its plans, commitments, and unresolved items\n\n{}\n\n---\n\n## New source material ({})\n\n{}",
namespace,
kind.dir(),
stem,
existing.trim_end(),
input_description,
input_body,
),
None => input_body.to_string(),
};
let merge_note = if has_existing {
"\n\nNote: the input begins with an existing draft of this log — content the user or agent pre-wrote (plans, appointments, notes). Merge its facts, commitments, and unresolved items into both the digest and the body. Do not drop draft content; only deduplicate when the new source material already covers the same point."
} else {
""
};
let system = format!(
"You are generating a concise {label} entry from {input_description}.{merge_note}
Output a Markdown file with this exact structure:
1. A YAML frontmatter block delimited by `---` lines, containing exactly one key:
`digest`: an array of 5–10 importance-ordered bullet points (strings). Each
bullet is a single short sentence capturing a key decision, topic, task
completed, or unresolved item. Order: most important first.
2. After the closing `---`, a blank line, then the Markdown summary body.
The body is free-form but should cover: key topics discussed, decisions
made, tasks completed, and unresolved items.
Do NOT include a top-level `#` heading — one will be added automatically.
Write in the same language as the source material (Japanese if it is in
Japanese). Emit raw Markdown, not a fenced code block."
);
let user_msg = ChatMessage::user(combined_input);
let response = provider.chat(Some(&system), &[user_msg], None).await?;
let raw = response
.text
.unwrap_or_else(|| String::from("(no summary generated)"));
let (digest, body) = parse_digest_response(&raw);
if digest.is_empty() {
warn!(
"{} for {stem}: LLM response had no digest array; writing with empty digest",
label
);
}
let heading = format!("# {label}: {stem}\n\n");
let file = serialize_log_file(&digest, &format!("{heading}{}\n", body.trim_end()))?;
let rel = log_rel_path(namespace, kind, stem);
ws_state
.lock()
.expect("WorkspaceState mutex poisoned")
.write_file(&rel, &file)?;
info!("{} written: {}", label, rel.display());
Ok(())
}
fn parse_digest_response(raw: &str) -> (Vec<String>, String) {
let cleaned = strip_code_fence(raw);
match crate::frontmatter::split(&cleaned) {
Some((fm, body)) => {
let items = crate::frontmatter::parse_mapping(fm)
.get("digest")
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
(items, body.trim_start_matches('\n').to_string())
}
None => (Vec::new(), cleaned.trim_start_matches('\n').to_string()),
}
}
fn strip_code_fence(raw: &str) -> String {
let trimmed = raw.trim();
let open_tags: &[&str] = &["```markdown\n", "```md\n", "```\n"];
for tag in open_tags {
if let Some(rest) = trimmed.strip_prefix(tag)
&& let Some(inner) = rest
.strip_suffix("```")
.or_else(|| rest.strip_suffix("```\n"))
{
return inner.trim_end().to_string();
}
}
trimmed.to_string()
}
fn yaml_value_to_string_vec(v: &serde_yaml::Value) -> Option<Vec<String>> {
let seq = v.as_sequence()?;
let mut out = Vec::with_capacity(seq.len());
for item in seq {
match item.as_str() {
Some(s) => out.push(s.trim().to_string()),
None => warn!("digest item is not a string: {item:?} — dropping"),
}
}
Some(out)
}
fn serialize_log_file(digest: &[String], body: &str) -> anyhow::Result<String> {
let mut mapping = serde_yaml::Mapping::new();
let seq = digest
.iter()
.map(|s| serde_yaml::Value::String(s.clone()))
.collect::<Vec<_>>();
mapping.insert(
serde_yaml::Value::String("digest".to_string()),
serde_yaml::Value::Sequence(seq),
);
crate::frontmatter::serialize(&mapping, body)
}
fn format_sessions(sessions: &[(SessionMeta, Vec<StoredMessage>)], date: NaiveDate) -> String {
let mut parts = vec![format!("Conversations for {date}:\n")];
for (meta, messages) in sessions {
let thread = meta.thread_id.as_deref().unwrap_or("main");
parts.push(format!(
"## Session {} (thread: {})\n",
meta.session_id, thread
));
for msg in messages {
let text: Vec<&str> = msg
.parts
.iter()
.filter_map(|p| {
if let ContentPart::Text(t) = p {
Some(t.as_str())
} else {
None
}
})
.collect();
if text.is_empty() {
continue;
}
let role_label = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
let local_ts = msg.timestamp.with_timezone(&Local);
parts.push(format!(
"[{}] {}: {}",
local_ts.format("%H:%M"),
role_label,
text.join(" ")
));
}
parts.push(String::new());
}
parts.join("\n")
}
fn needs_digest_catchup(raw: &str) -> bool {
match crate::frontmatter::split(raw) {
None => true,
Some((fm, _)) => {
let mapping = crate::frontmatter::parse_mapping(fm);
match mapping.get("digest").and_then(|v| v.as_sequence()) {
Some(seq) => seq.is_empty(),
None => true,
}
}
}
}
fn upsert_digest(raw: &str, digest: &[String]) -> anyhow::Result<String> {
let (mut mapping, body) = match crate::frontmatter::split(raw) {
Some((fm, body)) => (
crate::frontmatter::parse_mapping(fm),
body.trim_start_matches('\n').to_string(),
),
None => (serde_yaml::Mapping::new(), raw.to_string()),
};
let seq = digest
.iter()
.map(|s| serde_yaml::Value::String(s.clone()))
.collect::<Vec<_>>();
mapping.insert(
serde_yaml::Value::String("digest".to_string()),
serde_yaml::Value::Sequence(seq),
);
crate::frontmatter::serialize(&mapping, &body)
}
async fn extract_digest_from_body(
provider: &dyn Provider,
kind: LogKind,
body: &str,
) -> anyhow::Result<Vec<String>> {
let label = kind.heading_label();
let system = format!(
"You are extracting a concise digest from an existing {label} entry.
Output ONLY a YAML frontmatter block, exactly:
---
digest:
- …
- …
---
The `digest` array contains 5–10 importance-ordered bullet points (strings).
Each bullet is a single short sentence capturing a key decision, topic,
task completed, or unresolved item. Most important first. Write in the
same language as the source. Do NOT emit any text outside the frontmatter
block."
);
let user = ChatMessage::user(body);
let resp = provider.chat(Some(&system), &[user], None).await?;
let raw = resp.text.unwrap_or_default();
let (items, _) = parse_digest_response(&raw);
Ok(items)
}
fn body_without_frontmatter(raw: &str) -> String {
match crate::frontmatter::split(raw) {
Some((_, body)) => body.trim_start_matches('\n').to_string(),
None => raw.to_string(),
}
}
pub async fn catchup_missing_daily_digests(
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
) -> usize {
let dir = workspace_dir.join("memory").join(namespace).join("daily");
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return 0,
};
let mut pending = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let Ok(raw) = std::fs::read_to_string(&path) else {
continue;
};
if needs_digest_catchup(&raw) {
pending.push((path, raw));
}
}
if pending.is_empty() {
return 0;
}
info!("Back-filling digest for {} daily log(s)…", pending.len());
let mut filled = 0;
for (path, raw) in pending {
let display_path = path.display().to_string();
let body = body_without_frontmatter(&raw);
let items = match extract_digest_from_body(provider, LogKind::Daily, &body).await {
Ok(v) if !v.is_empty() => v,
Ok(_) => {
warn!("Empty digest extracted from {display_path} — skipping");
continue;
}
Err(e) => {
warn!("Failed to extract digest from {display_path}: {e:#}");
continue;
}
};
let new_raw = match upsert_digest(&raw, &items) {
Ok(s) => s,
Err(e) => {
warn!("Failed to merge digest for {display_path}: {e:#}");
continue;
}
};
let rel = path
.strip_prefix(workspace_dir)
.map(|p| p.to_path_buf())
.unwrap_or_else(|_| path.clone());
match ws_state
.lock()
.expect("WorkspaceState mutex poisoned")
.write_file(&rel, &new_raw)
{
Ok(()) => {
filled += 1;
info!("Digest added: {}", rel.display());
}
Err(e) => warn!("Failed to write digest for {display_path}: {e:#}"),
}
}
filled
}
pub async fn catchup_pending_daily_logs<F>(
session_store: &SessionStore,
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
boundary_hour: u8,
room_predicate: F,
) -> usize
where
F: Fn(&crate::session::SessionMeta) -> bool + Copy,
{
let pending = pending_daily_dates(
session_store,
workspace_dir,
namespace,
boundary_hour,
room_predicate,
);
if pending.is_empty() {
return 0;
}
info!(
"Generating {} pending daily log(s) for namespace '{}'…",
pending.len(),
namespace
);
let mut generated = 0;
for date in pending {
match generate_daily_log(
session_store,
provider,
ws_state,
workspace_dir,
namespace,
date,
boundary_hour,
room_predicate,
)
.await
{
Ok(true) => generated += 1,
Ok(false) => {}
Err(e) => warn!("Failed to generate daily log for {date} in '{namespace}': {e:#}"),
}
}
generated
}
fn existing_daily_dates(workspace_dir: &Path, namespace: &str) -> Vec<NaiveDate> {
let dir = workspace_dir.join("memory").join(namespace).join("daily");
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Ok(date) = NaiveDate::parse_from_str(stem, "%Y-%m-%d") {
out.push(date);
}
}
out
}
fn existing_monthly_year_months(workspace_dir: &Path, namespace: &str) -> Vec<(i32, u32)> {
let dir = workspace_dir.join("memory").join(namespace).join("monthly");
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let parts: Vec<&str> = stem.split('-').collect();
if parts.len() != 2 {
continue;
}
if let (Ok(y), Ok(m)) = (parts[0].parse::<i32>(), parts[1].parse::<u32>())
&& (1..=12).contains(&m)
{
out.push((y, m));
}
}
out
}
pub fn pending_iso_weeks(
workspace_dir: &Path,
namespace: &str,
today: NaiveDate,
) -> Vec<(i32, u32)> {
use std::collections::BTreeSet;
let current_iso = today.iso_week();
let current_key = (current_iso.year(), current_iso.week());
let mut weeks: BTreeSet<(i32, u32)> = BTreeSet::new();
for date in existing_daily_dates(workspace_dir, namespace) {
let iso = date.iso_week();
let key = (iso.year(), iso.week());
if key >= current_key {
continue;
}
weeks.insert(key);
}
weeks
.into_iter()
.filter(|(y, w)| {
!log_abs_path(
workspace_dir,
namespace,
LogKind::Weekly,
&weekly_stem(*y, *w),
)
.exists()
})
.collect()
}
pub fn pending_months(workspace_dir: &Path, namespace: &str, today: NaiveDate) -> Vec<(i32, u32)> {
use std::collections::BTreeSet;
let current_key = (today.year(), today.month());
let mut months: BTreeSet<(i32, u32)> = BTreeSet::new();
for date in existing_daily_dates(workspace_dir, namespace) {
let key = (date.year(), date.month());
if key >= current_key {
continue;
}
months.insert(key);
}
months
.into_iter()
.filter(|(y, m)| {
!log_abs_path(
workspace_dir,
namespace,
LogKind::Monthly,
&monthly_stem(*y, *m),
)
.exists()
})
.collect()
}
pub fn pending_years(workspace_dir: &Path, namespace: &str, today: NaiveDate) -> Vec<i32> {
use std::collections::BTreeSet;
let current_year = today.year();
let mut years: BTreeSet<i32> = BTreeSet::new();
for (y, _m) in existing_monthly_year_months(workspace_dir, namespace) {
if y >= current_year {
continue;
}
years.insert(y);
}
years
.into_iter()
.filter(|y| {
!log_abs_path(workspace_dir, namespace, LogKind::Yearly, &yearly_stem(*y)).exists()
})
.collect()
}
pub async fn catchup_pending_weekly_logs(
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
today: NaiveDate,
) -> usize {
let pending = pending_iso_weeks(workspace_dir, namespace, today);
if pending.is_empty() {
return 0;
}
info!(
"Generating {} pending weekly log(s) for namespace '{}'…",
pending.len(),
namespace
);
let mut generated = 0;
for (iso_year, iso_week) in pending {
match generate_weekly_log(
provider,
ws_state,
workspace_dir,
namespace,
iso_year,
iso_week,
)
.await
{
Ok(true) => generated += 1,
Ok(false) => {}
Err(e) => warn!(
"Failed to generate weekly log for {}-W{:02} in '{}': {e:#}",
iso_year, iso_week, namespace
),
}
}
generated
}
pub async fn catchup_pending_monthly_logs(
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
today: NaiveDate,
) -> usize {
let pending = pending_months(workspace_dir, namespace, today);
if pending.is_empty() {
return 0;
}
info!(
"Generating {} pending monthly log(s) for namespace '{}'…",
pending.len(),
namespace
);
let mut generated = 0;
for (year, month) in pending {
match generate_monthly_log(provider, ws_state, workspace_dir, namespace, year, month).await
{
Ok(true) => generated += 1,
Ok(false) => {}
Err(e) => warn!(
"Failed to generate monthly log for {:04}-{:02} in '{}': {e:#}",
year, month, namespace
),
}
}
generated
}
pub async fn catchup_pending_yearly_logs(
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
namespace: &str,
today: NaiveDate,
) -> usize {
let pending = pending_years(workspace_dir, namespace, today);
if pending.is_empty() {
return 0;
}
info!(
"Generating {} pending yearly log(s) for namespace '{}'…",
pending.len(),
namespace
);
let mut generated = 0;
for year in pending {
match generate_yearly_log(provider, ws_state, workspace_dir, namespace, year).await {
Ok(true) => generated += 1,
Ok(false) => {}
Err(e) => warn!(
"Failed to generate yearly log for {year:04} in '{}': {e:#}",
namespace
),
}
}
generated
}
pub fn build_today_digest_for_namespace<F>(
namespace: &str,
today: NaiveDate,
boundary_hour: u8,
channel_store: &SessionStore,
cross_device_store: Option<&SessionStore>,
device_default_store: Option<&SessionStore>,
room_to_namespace: F,
) -> Option<String>
where
F: Fn(&str) -> String,
{
let mut entries: Vec<(SessionMeta, crate::session::IntradayDigestLine)> = Vec::new();
entries.extend(channel_store.intraday_digests_for_day(today, boundary_hour));
if let Some(s) = cross_device_store {
entries.extend(s.intraday_digests_for_day(today, boundary_hour));
}
if let Some(s) = device_default_store {
entries.extend(s.intraday_digests_for_day(today, boundary_hour));
}
if entries.is_empty() {
return None;
}
let mut lines: Vec<String> = Vec::new();
for (meta, digest) in entries {
let is_rpc = meta.channel == "rpc";
let is_device_default = meta.channel == "device-default";
let ns = if is_rpc {
crate::config::DEFAULT_NAMESPACE_NAME.to_string()
} else if is_device_default {
meta.namespace
.clone()
.unwrap_or_else(|| crate::config::DEFAULT_NAMESPACE_NAME.to_string())
} else {
room_to_namespace(&meta.room_id)
};
if ns != namespace {
continue;
}
let room_label = if is_rpc || is_device_default {
meta.title
.clone()
.unwrap_or_else(|| format!("{}/{}", meta.channel, short_id(&meta.session_id)))
} else {
format!("{}/{}", meta.channel, short_id(&meta.room_id))
};
let local_ts = digest.digest_at.with_timezone(&Local);
lines.push(format!(
"- [{}, {}] {}",
local_ts.format("%H:%M"),
room_label,
digest.digest.trim()
));
}
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
pub fn build_all_today_digests<F>(
namespaces: &[String],
today: NaiveDate,
boundary_hour: u8,
channel_store: &SessionStore,
cross_device_store: Option<&SessionStore>,
device_default_store: Option<&SessionStore>,
room_to_namespace: F,
) -> HashMap<String, String>
where
F: Fn(&str) -> String + Copy,
{
let mut out = HashMap::new();
for ns in namespaces {
if let Some(text) = build_today_digest_for_namespace(
ns,
today,
boundary_hour,
channel_store,
cross_device_store,
device_default_store,
room_to_namespace,
) {
out.insert(ns.clone(), text);
}
}
out
}
fn short_id(id: &str) -> String {
if id.chars().count() <= 8 {
id.to_string()
} else {
id.chars().take(8).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stems_basic() {
let d = NaiveDate::from_ymd_opt(2026, 4, 15).unwrap();
assert_eq!(daily_stem(d), "2026-04-15");
assert_eq!(monthly_stem(2026, 4), "2026-04");
assert_eq!(yearly_stem(2026), "2026");
}
#[test]
fn weekly_stem_iso_year_may_differ_from_calendar_year() {
let d = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
let iso = d.iso_week();
assert_eq!(weekly_stem(iso.year(), iso.week()), "2022-W52");
let d = NaiveDate::from_ymd_opt(2024, 12, 30).unwrap();
let iso = d.iso_week();
assert_eq!(weekly_stem(iso.year(), iso.week()), "2025-W01");
}
#[test]
fn weekly_stem_zero_padded() {
assert_eq!(weekly_stem(2026, 3), "2026-W03");
assert_eq!(weekly_stem(2026, 16), "2026-W16");
}
#[test]
fn paths() {
let abs = log_abs_path(Path::new("/ws"), "default", LogKind::Weekly, "2026-W16");
assert_eq!(abs, PathBuf::from("/ws/memory/default/weekly/2026-W16.md"));
let rel = log_rel_path("default", LogKind::Monthly, "2026-04");
assert_eq!(rel, PathBuf::from("memory/default/monthly/2026-04.md"));
let abs = log_abs_path(Path::new("/ws"), "user_nsfw", LogKind::Daily, "2026-05-05");
assert_eq!(
abs,
PathBuf::from("/ws/memory/user_nsfw/daily/2026-05-05.md")
);
}
#[test]
fn parse_digest_response_happy_path() {
let raw = "---\ndigest:\n - item A\n - item B\n - item C\n---\n\n# body line\n";
let (items, body) = parse_digest_response(raw);
assert_eq!(items, vec!["item A", "item B", "item C"]);
assert_eq!(body.trim(), "# body line");
}
#[test]
fn parse_digest_response_missing_frontmatter() {
let raw = "# Just a body\n\nno digest here\n";
let (items, body) = parse_digest_response(raw);
assert!(items.is_empty());
assert!(body.contains("no digest here"));
}
#[test]
fn parse_digest_response_strips_code_fence() {
let raw = "```markdown\n---\ndigest:\n - x\n---\n\nbody\n```";
let (items, body) = parse_digest_response(raw);
assert_eq!(items, vec!["x"]);
assert_eq!(body.trim(), "body");
}
#[test]
fn parse_digest_response_drops_non_string_items() {
let raw = "---\ndigest:\n - ok\n - 42\n - also ok\n---\n\nbody\n";
let (items, _) = parse_digest_response(raw);
assert_eq!(items, vec!["ok", "also ok"]);
}
#[test]
fn serialize_log_file_includes_digest() {
let out = serialize_log_file(&["a".into(), "b".into()], "# H\n\nbody\n").unwrap();
assert!(out.starts_with("---\n"));
assert!(out.contains("digest:"));
assert!(out.contains("- a"));
assert!(out.contains("- b"));
assert!(out.contains("# H"));
assert!(out.contains("body"));
}
#[test]
fn needs_digest_catchup_cases() {
assert!(needs_digest_catchup("# body only\n"));
assert!(needs_digest_catchup("---\nother: 1\n---\n# body\n"));
assert!(needs_digest_catchup("---\ndigest: []\n---\n# body\n"));
assert!(!needs_digest_catchup(
"---\ndigest:\n - one\n---\n# body\n"
));
}
#[test]
fn upsert_digest_preserves_unrelated_keys() {
let raw = "---\nlast_read_at: 2026-04-15T21:57:41.312Z\nread_count: 3\n---\n\n# Daily Log: 2026-04-15\n\nbody\n";
let new = upsert_digest(raw, &["one".into(), "two".into()]).unwrap();
let (fm, body) = crate::frontmatter::split(&new).unwrap();
let mapping = crate::frontmatter::parse_mapping(fm);
assert!(mapping.get("last_read_at").is_some());
assert_eq!(mapping.get("read_count").and_then(|v| v.as_u64()), Some(3));
let digest: Vec<String> = mapping
.get("digest")
.and_then(|v| v.as_sequence())
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(digest, vec!["one", "two"]);
assert!(body.contains("# Daily Log: 2026-04-15"));
assert!(body.contains("body"));
}
#[test]
fn upsert_digest_synthesises_frontmatter_when_missing() {
let raw = "# Daily Log: 2026-04-15\n\nbody\n";
let new = upsert_digest(raw, &["only".into()]).unwrap();
assert!(new.starts_with("---\n"));
let (fm, body) = crate::frontmatter::split(&new).unwrap();
let mapping = crate::frontmatter::parse_mapping(fm);
let digest: Vec<String> = mapping
.get("digest")
.and_then(|v| v.as_sequence())
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(digest, vec!["only"]);
assert!(body.contains("# Daily Log: 2026-04-15"));
assert!(body.contains("body"));
}
#[test]
fn days_of_iso_week_returns_seven_ordered_days() {
let days = days_of_iso_week(2026, 16);
assert_eq!(days.len(), 7);
assert_eq!(days[0], NaiveDate::from_ymd_opt(2026, 4, 13).unwrap());
assert_eq!(days[6], NaiveDate::from_ymd_opt(2026, 4, 19).unwrap());
assert_eq!(days[0].weekday(), Weekday::Mon);
assert_eq!(days[6].weekday(), Weekday::Sun);
}
#[test]
fn daily_stems_this_week_on_thursday() {
let thu = NaiveDate::from_ymd_opt(2026, 4, 16).unwrap();
let stems = daily_stems_in_current_iso_week_before(thu);
assert_eq!(stems, vec!["2026-04-13", "2026-04-14"]);
}
#[test]
fn daily_stems_this_week_on_monday_is_empty() {
let mon = NaiveDate::from_ymd_opt(2026, 4, 13).unwrap();
assert!(daily_stems_in_current_iso_week_before(mon).is_empty());
}
#[test]
fn daily_stems_this_week_on_tuesday_is_empty() {
let tue = NaiveDate::from_ymd_opt(2026, 4, 14).unwrap();
assert!(daily_stems_in_current_iso_week_before(tue).is_empty());
}
#[test]
fn daily_stems_this_week_on_sunday_gives_four_days() {
let sun = NaiveDate::from_ymd_opt(2026, 4, 19).unwrap();
let stems = daily_stems_in_current_iso_week_before(sun);
assert_eq!(
stems,
vec![
"2026-04-13",
"2026-04-14",
"2026-04-15",
"2026-04-16",
"2026-04-17"
]
);
}
#[test]
fn days_of_month_april_2026_is_30_days() {
let days = days_of_month(2026, 4);
assert_eq!(days.len(), 30);
assert_eq!(days[0], NaiveDate::from_ymd_opt(2026, 4, 1).unwrap());
assert_eq!(days[29], NaiveDate::from_ymd_opt(2026, 4, 30).unwrap());
}
#[test]
fn days_of_month_december_is_31_days() {
assert_eq!(days_of_month(2026, 12).len(), 31);
}
#[test]
fn days_of_month_february_leap_year() {
assert_eq!(days_of_month(2024, 2).len(), 29);
assert_eq!(days_of_month(2025, 2).len(), 28);
}
#[test]
fn week_stems_in_month_before_april_16_2026() {
let thu = NaiveDate::from_ymd_opt(2026, 4, 16).unwrap();
let stems = week_stems_in_month_before(thu);
assert_eq!(stems, vec!["2026-W15", "2026-W17", "2026-W18"]);
}
#[test]
fn week_stems_in_month_before_may_1_2026() {
let fri = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let stems = week_stems_in_month_before(fri);
assert_eq!(stems, vec!["2026-W19", "2026-W20", "2026-W21", "2026-W22"]);
}
#[test]
fn month_stems_in_year_before_april() {
let apr = NaiveDate::from_ymd_opt(2026, 4, 16).unwrap();
assert_eq!(
month_stems_in_year_before(apr),
vec!["2026-01", "2026-02", "2026-03"]
);
}
#[test]
fn month_stems_in_year_before_january_is_empty() {
let jan = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
assert!(month_stems_in_year_before(jan).is_empty());
}
#[test]
fn month_stems_in_year_before_december() {
let dec = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
let stems = month_stems_in_year_before(dec);
assert_eq!(stems.len(), 11);
assert_eq!(stems.first().map(String::as_str), Some("2026-01"));
assert_eq!(stems.last().map(String::as_str), Some("2026-11"));
}
fn make_tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
fn write_stub(path: &Path, body: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}
#[test]
fn pending_iso_weeks_excludes_current_week_and_existing_files() {
let td = make_tempdir();
let root = td.path();
for d in [
"2026-04-06", "2026-04-09", "2026-04-13", "2026-04-14", "2026-03-30", ] {
write_stub(
&root.join("memory/default/daily").join(format!("{d}.md")),
"# body\n",
);
}
write_stub(
&root.join("memory/default/weekly/2026-W14.md"),
"---\ndigest: []\n---\nbody\n",
);
let today = NaiveDate::from_ymd_opt(2026, 4, 16).unwrap();
let pending = pending_iso_weeks(root, "default", today);
assert_eq!(pending, vec![(2026, 15)]);
}
#[test]
fn pending_months_excludes_current_month_and_existing_files() {
let td = make_tempdir();
let root = td.path();
for d in [
"2026-02-01",
"2026-02-15", "2026-03-05", "2026-04-02",
"2026-04-16", ] {
write_stub(
&root.join("memory/default/daily").join(format!("{d}.md")),
"# body\n",
);
}
write_stub(
&root.join("memory/default/monthly/2026-02.md"),
"---\ndigest: []\n---\nbody\n",
);
let today = NaiveDate::from_ymd_opt(2026, 4, 16).unwrap();
let pending = pending_months(root, "default", today);
assert_eq!(pending, vec![(2026, 3)]);
}
#[test]
fn pending_years_from_existing_monthlies() {
let td = make_tempdir();
let root = td.path();
for stem in ["2024-06", "2024-12", "2025-01", "2026-03"] {
write_stub(
&root
.join("memory/default/monthly")
.join(format!("{stem}.md")),
"# body\n",
);
}
write_stub(
&root.join("memory/default/yearly/2024.md"),
"---\ndigest: []\n---\nbody\n",
);
let today = NaiveDate::from_ymd_opt(2026, 4, 16).unwrap();
let pending = pending_years(root, "default", today);
assert_eq!(pending, vec![2025]);
}
#[test]
fn pending_enumerations_handle_missing_directories() {
let td = make_tempdir();
let today = NaiveDate::from_ymd_opt(2026, 4, 16).unwrap();
assert!(pending_iso_weeks(td.path(), "default", today).is_empty());
assert!(pending_months(td.path(), "default", today).is_empty());
assert!(pending_years(td.path(), "default", today).is_empty());
}
#[test]
fn upsert_digest_replaces_existing_digest() {
let raw = "---\ndigest:\n - stale\nother: keep\n---\n\nbody\n";
let new = upsert_digest(raw, &["fresh".into()]).unwrap();
let (fm, _) = crate::frontmatter::split(&new).unwrap();
let mapping = crate::frontmatter::parse_mapping(fm);
assert_eq!(mapping.get("other").and_then(|v| v.as_str()), Some("keep"));
let digest: Vec<String> = mapping
.get("digest")
.and_then(|v| v.as_sequence())
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(digest, vec!["fresh"]);
}
}