use serde::Serialize;
use crate::db::{RecentEvent, Session};
#[derive(Debug, Clone, Serialize)]
pub struct Summary {
pub project: String,
pub session: i64,
pub started_at: String,
pub ended_at: String,
pub decisions: Vec<String>,
pub findings: Vec<String>,
pub pitfalls: Vec<String>,
pub changes: Vec<String>,
pub questions: Vec<String>,
pub next_step: Option<String>,
pub shipped: Vec<String>,
pub tasks_closed: Vec<String>,
}
impl Summary {
pub fn empty(&self) -> bool {
self.decisions.is_empty()
&& self.findings.is_empty()
&& self.pitfalls.is_empty()
&& self.changes.is_empty()
&& self.questions.is_empty()
&& self.shipped.is_empty()
&& self.tasks_closed.is_empty()
}
pub fn recorded(&self) -> usize {
self.decisions.len() + self.findings.len() + self.pitfalls.len() + self.changes.len() + self.questions.len()
}
pub fn missing(&self) -> Vec<&'static str> {
let mut out = Vec::new();
if self.next_step.is_none() {
out.push("no next step - the next session will open without a first line");
}
if !self.changes.is_empty() && self.decisions.is_empty() && self.findings.is_empty() && self.pitfalls.is_empty() {
out.push("changes but no decision, finding or pitfall - nothing says why");
}
out
}
}
pub fn summarise(
project: &str,
session: &Session,
events: &[RecentEvent],
shipped: Vec<String>,
tasks_closed: Vec<String>,
next_step: Option<String>,
) -> Summary {
let mut summary = Summary {
project: project.to_string(),
session: session.id,
started_at: session.started_at.clone(),
ended_at: session.ended_at.clone().unwrap_or_default(),
decisions: Vec::new(),
findings: Vec::new(),
pitfalls: Vec::new(),
changes: Vec::new(),
questions: Vec::new(),
next_step,
shipped,
tasks_closed,
};
for event in events {
if event.from_git {
continue;
}
let into = match event.kind.as_str() {
"decision" => &mut summary.decisions,
"finding" => &mut summary.findings,
"pitfall" => &mut summary.pitfalls,
"change" => &mut summary.changes,
"question" => &mut summary.questions,
_ => continue,
};
into.push(event.body.clone());
}
summary
}
pub fn diary_entry(summary: &Summary, day: &str, heading: Option<&str>) -> String {
let title = match heading {
Some(text) if !text.trim().is_empty() => format!("## {day} · {}", text.trim()),
_ => format!("## {day}"),
};
let mut out = format!("{title}\n");
let section = |out: &mut String, label: &str, items: &[String]| {
if items.is_empty() {
return;
}
out.push_str(&format!("\n**{label}**\n"));
for item in items {
out.push_str(&format!("\n- {}\n", item.trim()));
}
};
if !summary.shipped.is_empty() {
out.push_str(&format!("\n**Выпущено.** {}\n", summary.shipped.join(", ")));
}
section(&mut out, "Сделано.", &summary.changes);
section(&mut out, "Решения.", &summary.decisions);
section(&mut out, "Находки.", &summary.findings);
section(&mut out, "Грабли.", &summary.pitfalls);
section(&mut out, "Ждёт владельца.", &summary.questions);
if !summary.tasks_closed.is_empty() {
out.push_str(&format!("\n**Закрыто задач:** {}\n", summary.tasks_closed.len()));
}
if let Some(next) = &summary.next_step {
out.push_str(&format!("\n**Следующий шаг.** {}\n", next.trim()));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> Session {
Session {
id: 7,
project_id: 1,
started_at: "2026-09-05T09:00:00Z".to_string(),
ended_at: Some("2026-09-05T17:00:00Z".to_string()),
}
}
fn event(kind: &str, body: &str, from_git: bool) -> RecentEvent {
RecentEvent {
kind: kind.to_string(),
date: "2026-09-05".to_string(),
body: body.to_string(),
from_git,
}
}
#[test]
fn events_are_sorted_by_what_they_are() {
let events = vec![
event("decision", "the tier carries its rhythm", false),
event("finding", "the grid wraps at 46 releases", false),
event("pitfall", "a byte index panics on a dash", false),
event("change", "added the calendar", false),
event("question", "which tier for dowel?", false),
];
let summary = summarise("alpha", &session(), &events, vec![], vec![], None);
assert_eq!(summary.decisions.len(), 1);
assert_eq!(summary.findings.len(), 1);
assert_eq!(summary.pitfalls.len(), 1);
assert_eq!(summary.changes.len(), 1);
assert_eq!(summary.questions.len(), 1);
assert_eq!(summary.recorded(), 5);
}
#[test]
fn the_chronicle_from_git_is_not_what_a_session_did() {
let events = vec![
event("change", "feat: read tags into facts", true),
event("change", "wrote the session summary", false),
];
let summary = summarise("alpha", &session(), &events, vec![], vec![], None);
assert_eq!(summary.changes, vec!["wrote the session summary"]);
assert_eq!(summary.recorded(), 1);
}
#[test]
fn a_session_that_recorded_nothing_says_so() {
let summary = summarise("alpha", &session(), &[], vec![], vec![], None);
assert!(summary.empty());
let shipped = summarise("alpha", &session(), &[], vec!["v0.1.0".to_string()], vec![], None);
assert!(!shipped.empty());
}
#[test]
fn the_end_names_what_the_ritual_asks_for_and_did_not_get() {
let bare = summarise("alpha", &session(), &[event("change", "did a thing", false)], vec![], vec![], None);
let missing = bare.missing();
assert!(missing.iter().any(|m| m.contains("next step")), "{missing:?}");
assert!(missing.iter().any(|m| m.contains("nothing says why")), "{missing:?}");
let whole = summarise(
"alpha",
&session(),
&[event("change", "did a thing", false), event("decision", "because", false)],
vec![],
vec![],
Some("carry on".to_string()),
);
assert!(whole.missing().is_empty(), "{:?}", whole.missing());
}
#[test]
fn a_session_that_only_decided_is_not_asked_why() {
let summary = summarise(
"alpha",
&session(),
&[event("decision", "we will use ISO weeks", false)],
vec![],
vec![],
Some("start on it".to_string()),
);
assert!(summary.missing().is_empty(), "{:?}", summary.missing());
}
#[test]
fn the_diary_entry_is_the_sessions_own_sentences() {
let summary = summarise(
"alpha",
&session(),
&[
event("change", "added the calendar", false),
event("decision", "the tier carries its rhythm", false),
event("pitfall", "the grid wraps at 46 releases", false),
],
vec!["v0.10.0".to_string()],
vec!["a task".to_string()],
Some("ship the retro".to_string()),
);
let entry = diary_entry(&summary, "2026-09-05", Some("v0.10.0 «Календарь»"));
assert!(entry.starts_with("## 2026-09-05 · v0.10.0 «Календарь»"), "{entry}");
assert!(entry.contains("**Выпущено.** v0.10.0"), "{entry}");
assert!(entry.contains("added the calendar"), "{entry}");
assert!(entry.contains("the tier carries its rhythm"), "{entry}");
assert!(entry.contains("the grid wraps at 46 releases"), "{entry}");
assert!(entry.contains("**Закрыто задач:** 1"), "{entry}");
assert!(entry.contains("**Следующий шаг.** ship the retro"), "{entry}");
assert!(!entry.contains("**Находки.**"), "{entry}");
}
#[test]
fn a_diary_entry_without_a_heading_still_names_its_day() {
let summary = summarise("alpha", &session(), &[event("change", "a thing", false)], vec![], vec![], None);
let entry = diary_entry(&summary, "2026-09-05", None);
assert!(entry.starts_with("## 2026-09-05\n"), "{entry}");
let blank = diary_entry(&summary, "2026-09-05", Some(" "));
assert!(blank.starts_with("## 2026-09-05\n"), "{blank}");
}
}