use serde::Serialize;
use crate::db::{DigestFacts, Waiting};
#[derive(Debug, Serialize)]
pub struct Shared {
pub subject: String,
pub projects: Vec<String>,
}
pub fn subject(body: &str) -> String {
let first = body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim();
let first = first
.trim_start_matches(|c: char| !c.is_alphanumeric() && c != '*' && c != '`')
.trim_start_matches("[ ]")
.trim_start_matches("[x]")
.trim();
if let Some(rest) = first.strip_prefix("**")
&& let Some(end) = rest.find("**")
{
let heading = rest[..end].trim().trim_end_matches([':', '.', ',', '—', '-']).trim();
if !heading.is_empty() {
return heading.to_string();
}
}
let chars: Vec<char> = first.chars().collect();
for (i, c) in chars.iter().enumerate() {
let ends = match c {
'?' | '!' => true,
':' | '—' => true,
'.' => chars.get(i + 1).is_none_or(|next| next.is_whitespace()),
_ => false,
};
if ends {
let keep = if matches!(c, '?' | '!') { i + 1 } else { i };
let cut: String = chars[..keep].iter().collect();
let cut = cut.trim().trim_end_matches([':', '—', '-']).trim();
if !cut.is_empty() {
return cut.to_string();
}
}
}
first.trim_end_matches(['.', ':', '—']).trim().to_string()
}
pub fn shared_subjects(waiting: &[Waiting]) -> Vec<Shared> {
let mut groups: Vec<(String, String, Vec<String>)> = Vec::new();
for question in waiting {
let shown = subject(&question.body);
let key = normalise(&shown);
if key.is_empty() {
continue;
}
match groups.iter_mut().find(|(k, _, _)| *k == key) {
Some((_, _, projects)) => {
if !projects.contains(&question.project) {
projects.push(question.project.clone());
}
}
None => groups.push((key, shown, vec![question.project.clone()])),
}
}
let mut shared: Vec<Shared> = groups
.into_iter()
.filter(|(_, _, projects)| projects.len() > 1)
.map(|(_, subject, projects)| Shared { subject, projects })
.collect();
shared.sort_by(|a, b| b.projects.len().cmp(&a.projects.len()).then(a.subject.cmp(&b.subject)));
shared
}
fn normalise(subject: &str) -> String {
subject
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub fn stage_name(stage: &str) -> String {
let cut = stage.find(" *(").or_else(|| stage.find(" (")).unwrap_or(stage.len());
let name = stage[..cut].trim().trim_end_matches(['*', '—', '-', ',']).trim();
if name.is_empty() { stage.trim().to_string() } else { name.to_string() }
}
pub fn digest_lines(facts: &DigestFacts, next_stage: Option<&str>, quiet_days: Option<i64>) -> Vec<String> {
let mut lines = Vec::new();
if !facts.shipped.is_empty() {
lines.push(match facts.shipped.len() {
1 => format!("shipped {}", facts.shipped[0]),
_ => format!(
"shipped {} — {} ({} releases)",
facts.shipped.first().map(String::as_str).unwrap_or(""),
facts.shipped.last().map(String::as_str).unwrap_or(""),
facts.shipped.len()
),
});
}
let mut recorded = Vec::new();
if facts.decisions > 0 {
recorded.push(plural(facts.decisions, "decision", "decisions"));
}
if facts.findings > 0 {
recorded.push(plural(facts.findings, "finding", "findings"));
}
if facts.changes > 0 {
recorded.push(plural(facts.changes, "change", "changes"));
}
if !recorded.is_empty() {
lines.push(format!("recorded {}", recorded.join(", ")));
}
if facts.waiting > 0 {
lines.push(format!("waiting on you: {}", plural(facts.waiting, "question", "questions")));
}
if let Some(stage) = next_stage {
lines.push(format!("next: {}", stage_name(stage)));
}
if lines.is_empty()
&& let Some(days) = quiet_days
{
lines.push(match days {
0 => "nothing recorded, though something happened today".to_string(),
1 => "nothing this week; last touched yesterday".to_string(),
n => format!("nothing this week; last touched {n} days ago"),
});
}
lines
}
fn plural(n: u32, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
#[cfg(test)]
mod tests {
use super::*;
fn waiting(project: &str, body: &str) -> Waiting {
Waiting {
project: project.into(),
id: 1,
date: "2026-09-04".into(),
body: body.into(),
}
}
#[test]
fn a_bold_heading_is_the_subject() {
assert_eq!(subject("**Место в календаре:** ярус не назначен."), "Место в календаре");
assert_eq!(subject("**Social preview** - upload it by hand."), "Social preview");
}
#[test]
fn a_question_without_a_heading_still_has_a_subject() {
assert_eq!(subject("Which tier is this project? It matters for the rhythm."), "Which tier is this project?");
assert_eq!(subject("Pick the release day"), "Pick the release day");
}
#[test]
fn a_leading_blank_line_does_not_swallow_the_subject() {
assert_eq!(subject("\n\n**The subject.** Detail."), "The subject");
}
#[test]
fn questions_spelt_alike_across_projects_are_grouped() {
let questions = vec![
waiting("austeris", "**Место в производственном календаре:** ярус не назначен."),
waiting("dowel", "**Место в производственном календаре** — ярус B."),
waiting("kasl-server", "**Место в производственном календаре:** в какой ярус."),
waiting("kilna", "**Публичный анонс** — привязан к v1.0.0."),
];
let shared = shared_subjects(&questions);
assert_eq!(shared.len(), 1, "{shared:?}");
assert_eq!(shared[0].projects.len(), 3);
assert!(shared[0].projects.contains(&"dowel".to_string()));
assert!(!shared.iter().any(|s| s.subject.contains("анонс")), "{shared:?}");
}
#[test]
fn one_project_asking_twice_is_not_a_group() {
let questions = vec![
waiting("dowel", "**The same subject.** First."),
waiting("dowel", "**The same subject.** Second."),
];
assert!(shared_subjects(&questions).is_empty());
}
#[test]
fn the_most_shared_question_comes_first() {
let questions = vec![
waiting("a", "**Two.** x"),
waiting("b", "**Two.** x"),
waiting("c", "**Three.** y"),
waiting("d", "**Three.** y"),
waiting("e", "**Three.** y"),
];
let shared = shared_subjects(&questions);
assert_eq!(shared[0].subject, "Three");
assert_eq!(shared[0].projects.len(), 3);
}
fn facts(shipped: &[&str], decisions: u32, findings: u32, changes: u32, waiting: u32) -> DigestFacts {
DigestFacts {
shipped: shipped.iter().map(|s| s.to_string()).collect(),
decisions,
findings,
changes,
waiting,
}
}
#[test]
fn a_digest_is_five_lines_at_most() {
let busy = facts(&["v0.6.0", "v0.7.0", "v0.8.0"], 12, 9, 40, 3);
let lines = digest_lines(&busy, Some("v0.9.0 · Inbox"), Some(0));
assert!(lines.len() <= 5, "{lines:#?}");
assert!(lines.iter().any(|l| l.contains("3 releases")), "{lines:#?}");
assert!(lines.iter().any(|l| l.contains("waiting on you: 3 questions")), "{lines:#?}");
}
#[test]
fn a_stage_name_drops_the_hub_aside() {
assert_eq!(
stage_name("v0.13.1 · MCP и Agent Skills *(отложено из v0.13.0, решение владельца 02.09)*"),
"v0.13.1 · MCP и Agent Skills"
);
assert_eq!(stage_name("v0.9.0 · Inbox and digest"), "v0.9.0 · Inbox and digest");
assert!(!stage_name("*(pending)*").is_empty());
}
#[test]
fn a_single_release_is_named_rather_than_counted() {
let lines = digest_lines(&facts(&["v1.0.0"], 0, 0, 0, 0), None, Some(0));
assert_eq!(lines[0], "shipped v1.0.0");
}
#[test]
fn silence_is_reported_as_a_fact() {
let lines = digest_lines(&facts(&[], 0, 0, 0, 0), None, Some(14));
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("14 days ago"), "{lines:?}");
}
#[test]
fn counts_agree_with_their_nouns() {
let lines = digest_lines(&facts(&[], 1, 0, 1, 1), None, None);
assert!(lines.iter().any(|l| l.contains("1 decision, 1 change")), "{lines:?}");
assert!(lines.iter().any(|l| l.contains("1 question")), "{lines:?}");
}
}