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() && !matches!(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>, signal: Option<&str>) -> Vec<String> {
let mut lines = Vec::new();
if let Some(signal) = signal {
lines.push(signal.to_string());
}
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
}
const BLOCK_START: &str = "<!-- rigger digest -->";
const BLOCK_END: &str = "<!-- /rigger digest -->";
pub fn digest_markdown(since: &str, listed: &[(String, Vec<String>)], quiet: &[String]) -> String {
let mut out = format!("{BLOCK_START}\n## rigger · since {since}\n\n");
if listed.is_empty() {
out.push_str("Nothing moved.\n");
}
for (name, lines) in listed {
out.push_str(&format!("**{name}**\n"));
for line in lines {
out.push_str(&format!("- {line}\n"));
}
out.push('\n');
}
if !quiet.is_empty() {
out.push_str(&format!("Quiet: {}\n", quiet.join(", ")));
}
let trimmed = out.trim_end().to_string();
format!("{trimmed}\n{BLOCK_END}\n")
}
pub fn write_block(path: &std::path::Path, block: &str) -> anyhow::Result<crate::db::Change> {
use anyhow::Context;
let existing = match std::fs::read_to_string(path) {
Ok(text) => Some(text),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(e).with_context(|| format!("cannot read {}", path.display())),
};
let (text, change) = match existing {
None => (block.to_string(), crate::db::Change::Added),
Some(text) => match (text.find(BLOCK_START), text.find(BLOCK_END)) {
(Some(start), Some(end)) if end > start => {
let after = end + BLOCK_END.len();
let rest = text[after..]
.strip_prefix("\r\n")
.or_else(|| text[after..].strip_prefix('\n'))
.unwrap_or(&text[after..]);
let replaced = format!("{}{block}{rest}", &text[..start]);
let change = if replaced == text {
crate::db::Change::Unchanged
} else {
crate::db::Change::Updated
};
(replaced, change)
}
_ => {
let gap = match text.is_empty() || text.ends_with("\n\n") {
true => "",
false if text.ends_with('\n') => "\n",
false => "\n\n",
};
(format!("{text}{gap}{block}"), crate::db::Change::Added)
}
},
};
if change != crate::db::Change::Unchanged {
std::fs::write(path, text).with_context(|| format!("cannot write {}", path.display()))?;
}
Ok(change)
}
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(),
due: None,
overdue: false,
}
}
#[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), None);
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), None);
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), None);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("14 days ago"), "{lines:?}");
}
#[test]
fn a_signal_leads_the_digest_and_displaces_the_silence() {
let signal = "tier A asks for more: 7 weeks without a release";
let lines = digest_lines(&facts(&[], 0, 0, 0, 0), None, Some(30), Some(signal));
assert_eq!(lines[0], signal);
assert!(!lines.iter().any(|l| l.contains("nothing this week")), "{lines:?}");
}
#[test]
fn a_digest_with_a_signal_is_still_five_lines_at_most() {
let busy = facts(&["v0.6.0", "v0.7.0"], 12, 9, 40, 3);
let lines = digest_lines(
&busy,
Some("v0.9.0 · Inbox"),
Some(0),
Some("tier B asks for more: no turn in the focus for 7 weeks"),
);
assert!(lines.len() <= 5, "{} lines: {lines:#?}", lines.len());
}
#[test]
fn counts_agree_with_their_nouns() {
let lines = digest_lines(&facts(&[], 1, 0, 1, 1), None, None, None);
assert!(lines.iter().any(|l| l.contains("1 decision, 1 change")), "{lines:?}");
assert!(lines.iter().any(|l| l.contains("1 question")), "{lines:?}");
}
}