use std::fmt::Write as _;
use serde::Serialize;
use crate::hub::{DiaryEntry, Prose, Stage, StateLine};
pub const GENERATED: [&str; 4] = ["План.md", "Изменения.md", "Дневник.md", "README.md"];
#[derive(Debug, Clone, Serialize)]
pub struct Written {
pub file: String,
pub bytes: usize,
pub unchanged: bool,
}
pub const EMPTY_QUEUE: &str = "- (пусто)";
pub const MARK: &str = "<!-- generated by rigger; edits here are overwritten -->";
pub fn diff(before: &str, after: &str, context: usize) -> Vec<String> {
let old: Vec<&str> = before.lines().collect();
let new: Vec<&str> = after.lines().collect();
let head = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
let tail = old[head..].iter().rev().zip(new[head..].iter().rev()).take_while(|(a, b)| a == b).count();
if head == old.len() && old.len() == new.len() {
return Vec::new();
}
let mut out = Vec::new();
let from = head.saturating_sub(context);
for line in &old[from..head] {
out.push(format!(" {line}"));
}
for line in &old[head..old.len() - tail] {
out.push(format!("- {line}"));
}
for line in &new[head..new.len() - tail] {
out.push(format!("+ {line}"));
}
let tail_start = old.len() - tail;
for line in &old[tail_start..(tail_start + context).min(old.len())] {
out.push(format!(" {line}"));
}
out
}
pub fn is_generated(text: &str) -> bool {
text.lines().take(5).any(|l| l.trim() == MARK)
}
pub fn plan(prose: &[Prose], stages: &[Stage], questions: &[String]) -> String {
let mut out = String::new();
out.push_str(MARK);
out.push_str("\n\n");
interleave(&mut out, prose, stages, questions);
finish(out)
}
pub fn changes(prose: &[Prose], stages: &[Stage]) -> String {
let mut out = String::new();
out.push_str(MARK);
out.push_str("\n\n");
interleave(&mut out, prose, stages, &[]);
finish(out)
}
fn interleave(out: &mut String, prose: &[Prose], stages: &[Stage], questions: &[String]) {
let mut next = 0usize;
for (n, run) in prose.iter().enumerate() {
write_run(out, run, questions);
while next < stages.len() && stages[next].after_prose <= n + 1 {
write_stage(out, &stages[next]);
next += 1;
}
}
for stage in &stages[next..] {
write_stage(out, stage);
}
}
pub fn diary(prose: &[Prose], entries: &[DiaryEntry]) -> String {
let mut out = String::new();
out.push_str(MARK);
out.push_str("\n\n");
for run in prose {
write_run(&mut out, run, &[]);
}
for entry in entries {
let _ = match &entry.heading {
Some(heading) => writeln!(out, "## {}\n", heading.trim()),
None => writeln!(out, "## {}\n", entry.date),
};
if !entry.body.trim().is_empty() {
out.push_str(entry.body.trim());
out.push('\n');
for _ in 0..if entry.followed_by_rule { 1 } else { entry.gap_after } {
out.push('\n');
}
}
if entry.followed_by_rule {
out.push_str("---\n");
for _ in 0..entry.gap_after {
out.push('\n');
}
}
}
finish(out)
}
pub fn readme(prose: &[Prose], state: &[StateLine]) -> String {
let mut out = String::new();
out.push_str(MARK);
out.push_str("\n\n");
for run in prose {
if !state.is_empty()
&& run
.heading
.as_deref()
.is_some_and(|h| h.trim_start_matches('#').trim().starts_with("Состояние"))
{
out.push_str(run.heading.as_deref().unwrap().trim_end());
out.push_str("\n\n");
for line in state {
let _ = match &line.stamp {
Some(stamp) => writeln!(out, "- **{}** — {}", stamp.trim(), line.body.trim()),
None => writeln!(out, "- {}", line.body.trim()),
};
for _ in 0..line.gap_after {
out.push('\n');
}
}
out.push('\n');
continue;
}
write_run(&mut out, run, &[]);
}
finish(out)
}
fn write_run(out: &mut String, run: &Prose, questions: &[String]) {
if let Some(heading) = &run.heading {
out.push_str(heading.trim_end());
out.push_str("\n\n");
}
if run.heading.as_deref().is_some_and(|h| h.contains("Ждёт решения владельца")) {
write_questions(out, run, questions);
return;
}
if !run.body.trim().is_empty() {
out.push_str(run.body.trim());
out.push('\n');
for _ in 0..run.gap_after {
out.push('\n');
}
}
}
fn item_marker(line: &str) -> Option<&str> {
let t = line.trim_start();
for lead in ["- [ ] ", "- [x] ", "- [X] ", "- ", "* "] {
if t.starts_with(lead) {
return Some(lead);
}
}
let (n, _) = t.split_once(". ")?;
(!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then_some("")
}
struct ListStyle<'a> {
marker: &'a str,
spaced: bool,
}
fn list_style(body: &str) -> ListStyle<'_> {
let mut style = ListStyle { marker: "", spaced: false };
let mut seen = false;
let mut blank_since_item = false;
for line in body.lines() {
match item_marker(line) {
Some(marker) => {
match seen {
false => {
style.marker = marker;
seen = true;
}
true if blank_since_item => style.spaced = true,
true => {}
}
blank_since_item = false;
}
None if line.trim().is_empty() => blank_since_item = true,
None => blank_since_item = false,
}
}
style
}
fn empty_marker(body: &str) -> String {
body.lines()
.find(|l| item_marker(l).is_some())
.map(|l| l.trim().to_string())
.unwrap_or_else(|| match body.trim().is_empty() {
true => EMPTY_QUEUE.to_string(),
false => String::new(),
})
}
fn split_around_list(body: &str) -> (String, String) {
let lines: Vec<&str> = body.lines().collect();
let first = lines.iter().position(|l| item_marker(l).is_some());
let last = lines.iter().rposition(|l| item_marker(l).is_some());
match (first, last) {
(Some(first), Some(last)) => (lines[..first].join("\n").trim().to_string(), lines[last + 1..].join("\n").trim().to_string()),
_ => (String::new(), body.trim().to_string()),
}
}
fn write_questions(out: &mut String, run: &Prose, questions: &[String]) {
let body = run.body.trim();
let style = list_style(body);
let (before, after) = split_around_list(body);
let placeholder = empty_marker(body);
if !before.is_empty() {
out.push_str(&before);
out.push_str("\n\n");
}
if questions.is_empty() && !placeholder.is_empty() {
out.push_str(&placeholder);
out.push_str("\n\n");
}
let last = questions.len().saturating_sub(1);
for (n, question) in questions.iter().enumerate() {
match style.marker {
"" => {
let _ = write!(out, "{}. {}", n + 1, question.trim());
}
lead => {
let _ = write!(out, "{lead}{}", question.trim());
}
}
out.push('\n');
if style.spaced || n == last {
out.push('\n');
}
}
if !after.is_empty() {
out.push_str(&after);
out.push('\n');
for _ in 0..run.gap_after {
out.push('\n');
}
}
}
fn write_stage(out: &mut String, stage: &Stage) {
let hashes = "#".repeat(stage.depth.clamp(1, 6));
let heading = match stage.heading.trim() {
"" => {
let title = stage.title.as_deref().map(|t| format!(" · {t}")).unwrap_or_default();
let shipped = stage.shipped_on.as_deref().map(|d| format!(" — выпущен {d}")).unwrap_or_default();
format!("{}{title}{shipped}", stage.version)
}
written => written.to_string(),
};
let _ = writeln!(out, "{hashes} {heading}\n");
let notes = stage.notes.trim();
let closes_here = stage.tasks.is_empty() && stage.notes_after.trim().is_empty();
if !notes.is_empty() {
out.push_str(notes);
out.push('\n');
for _ in 0..if closes_here { stage.gap_after } else { 1 } {
out.push('\n');
}
}
for task in &stage.tasks {
let mark = if task.done { "x" } else { " " };
let _ = writeln!(out, "- [{mark}] {}", task.title.trim());
}
if !stage.tasks.is_empty() {
out.push('\n');
}
let after = stage.notes_after.trim();
if !after.is_empty() {
out.push_str(after);
out.push('\n');
for _ in 0..stage.gap_after {
out.push('\n');
}
}
}
pub fn line_ending(existing: &str) -> &'static str {
match existing.contains("\r\n") {
true => "\r\n",
false => "\n",
}
}
pub fn with_line_ending(text: &str, ending: &str) -> String {
match ending {
"\r\n" => text.replace('\n', "\r\n"),
_ => text.to_string(),
}
}
fn finish(mut out: String) -> String {
while out.ends_with('\n') {
out.pop();
}
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hub::Task;
fn stage(version: &str, title: Option<&str>, shipped: Option<&str>, notes: &str, tasks: &[(&str, bool)]) -> Stage {
Stage {
version: version.to_string(),
title: title.map(str::to_string),
shipped_on: shipped.map(str::to_string),
notes: notes.to_string(),
depth: 2,
notes_after: String::new(),
heading: String::new(),
after_prose: usize::MAX,
gap_after: 1,
rank: 0,
tasks: tasks
.iter()
.map(|(t, done)| Task {
title: t.to_string(),
done: *done,
})
.collect(),
}
}
fn run(position: usize, heading: Option<&str>, body: &str) -> Prose {
Prose {
file: "План.md".to_string(),
position,
heading: heading.map(str::to_string),
body: body.to_string(),
gap_after: 1,
}
}
#[test]
fn a_generated_file_says_so_at_the_top() {
let text = plan(&[], &[stage("v0.1.0", Some("First"), None, "", &[])], &[]);
assert!(is_generated(&text), "{text}");
assert!(text.starts_with(MARK), "{text}");
assert!(!is_generated("# План\n\nПрроза.\n"));
}
#[test]
fn the_same_record_writes_the_same_bytes() {
let prose = [run(0, Some("# План"), "Преамбула.")];
let stages = [stage("v0.1.0", Some("First"), None, "Заметка.", &[("one", false)])];
let first = plan(&prose, &stages, &["Вопрос?".to_string()]);
let second = plan(&prose, &stages, &["Вопрос?".to_string()]);
assert_eq!(first, second);
assert!(!MARK.contains(char::is_numeric), "the mark carries a number that will change: {MARK}");
}
#[test]
fn a_file_ends_with_exactly_one_newline() {
for text in [
plan(&[], &[stage("v0.1.0", None, None, "", &[("one", false)])], &[]),
plan(&[], &[stage("v0.1.0", None, None, "note", &[])], &[]),
plan(&[run(0, Some("# План"), "Преамбула.")], &[], &[]),
plan(&[], &[], &[]),
] {
assert!(text.ends_with('\n'), "{text:?}");
assert!(!text.ends_with("\n\n"), "{text:?}");
}
}
#[test]
fn a_stage_keeps_its_prose_and_its_tasks() {
let text = changes(
&[],
&[stage(
"v0.2.0",
Some("Second"),
Some("2026-09-05"),
"Что было сделано и почему.",
&[("done thing", true), ("open thing", false)],
)],
);
assert!(text.contains("## v0.2.0 · Second — выпущен 2026-09-05"), "{text}");
assert!(text.contains("Что было сделано и почему."), "{text}");
assert!(text.contains("- [x] done thing"), "{text}");
assert!(text.contains("- [ ] open thing"), "{text}");
}
#[test]
fn the_owners_questions_are_written_under_their_heading() {
let prose = [run(0, Some("# План"), "Преамбула."), run(1, Some("## Ждёт решения владельца"), "")];
let text = plan(&prose, &[], &["Первый вопрос?".to_string(), "Второй.".to_string()]);
let at_heading = text.find("Ждёт решения владельца").unwrap();
let at_first = text.find("Первый вопрос?").unwrap();
assert!(at_heading < at_first, "{text}");
assert!(text.contains("1. Первый вопрос?"), "{text}");
assert!(text.contains("2. Второй."), "{text}");
}
#[test]
fn the_queue_keeps_the_shape_the_hub_wrote_it_in() {
let questions = ["Первый?".to_string(), "Второй?".to_string()];
let shape = |body: &str| {
let prose = [run(0, Some("## Ждёт решения владельца"), body)];
plan(&prose, &[], &questions)
};
assert!(
shape("1. Старый первый.\n2. Старый второй.").contains("1. Первый?\n2. Второй?"),
"packed numbers"
);
assert!(
shape("1. Старый первый.\n\n2. Старый второй.").contains("1. Первый?\n\n2. Второй?"),
"spaced numbers"
);
assert!(shape("- Старый первый.\n- Старый второй.").contains("- Первый?\n- Второй?"), "bullets");
assert!(shape("- [ ] Старый первый.").contains("- [ ] Первый?"), "checkboxes");
}
#[test]
fn prose_around_the_queue_keeps_its_side() {
let prose = [run(0, Some("## Ждёт решения владельца"), "Введение.\n\n- Старый.\n\n---")];
let text = plan(&prose, &[], &["Вопрос?".to_string()]);
let at_intro = text.find("Введение.").unwrap();
let at_question = text.find("Вопрос?").unwrap();
let at_rule = text.find("---").unwrap();
assert!(at_intro < at_question && at_question < at_rule, "{text}");
}
#[test]
fn a_stage_keeps_prose_on_both_sides_of_its_list() {
let mut s = stage("v0.1.0", None, None, "Что делаем.", &[("Первая", false), ("Вторая", false)]);
s.notes_after = "**Результат:** сделано.".to_string();
let text = plan(&[], &[s], &[]);
let at_intro = text.find("Что делаем.").unwrap();
let at_task = text.find("Первая").unwrap();
let at_result = text.find("**Результат:**").unwrap();
assert!(at_intro < at_task, "the opening prose stands above the list\n{text}");
assert!(at_task < at_result, "the closing prose stands below the list\n{text}");
}
#[test]
fn a_diary_entry_keeps_its_date_and_heading() {
let entries = [
DiaryEntry {
date: "2026-09-05".to_string(),
heading: Some("2026-09-05 · v0.13.0 «Сессии»".to_string()),
body: "Что делали.".to_string(),
followed_by_rule: false,
gap_after: 1,
rank: 0,
},
DiaryEntry {
date: "2026-09-01".to_string(),
heading: None,
body: "Раньше.".to_string(),
followed_by_rule: false,
gap_after: 1,
rank: 0,
},
];
let text = diary(&[], &entries);
assert!(text.contains("## 2026-09-05 · v0.13.0 «Сессии»"), "{text}");
assert!(text.contains("## 2026-09-01\n"), "{text}");
assert!(text.find("2026-09-05").unwrap() < text.find("2026-09-01").unwrap(), "{text}");
}
#[test]
fn stages_are_written_even_without_any_prose() {
let text = plan(&[], &[stage("v0.1.0", Some("First"), None, "", &[])], &[]);
assert!(text.contains("## v0.1.0 · First"), "{text}");
}
#[test]
fn stages_follow_the_prose_that_framed_them() {
let prose = [run(0, Some("# План"), "Преамбула."), run(1, Some("## Блок"), "")];
let text = plan(&prose, &[stage("v0.1.0", None, None, "", &[])], &[]);
assert!(text.find("Преамбула.").unwrap() < text.find("## v0.1.0").unwrap(), "{text}");
assert!(text.find("## Блок").unwrap() < text.find("## v0.1.0").unwrap(), "{text}");
}
}