use std::path::Path;
use anyhow::{Context, Result, bail};
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Stage {
pub version: String,
pub title: Option<String>,
pub shipped_on: Option<String>,
pub tasks: Vec<Task>,
pub notes: String,
pub depth: usize,
pub notes_after: String,
pub heading: String,
pub after_prose: usize,
pub gap_after: usize,
pub rank: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Task {
pub title: String,
pub done: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Decision {
pub date: String,
pub title: String,
pub body: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Prose {
pub file: String,
pub position: usize,
pub heading: Option<String>,
pub body: String,
pub gap_after: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DiaryEntry {
pub date: String,
pub heading: Option<String>,
pub body: String,
pub followed_by_rule: bool,
pub gap_after: usize,
pub rank: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StateLine {
pub stamp: Option<String>,
pub body: String,
pub gap_after: usize,
}
#[derive(Debug, Default)]
pub struct Hub {
pub open_stages: Vec<Stage>,
pub closed_stages: Vec<Stage>,
pub decisions: Vec<Decision>,
pub questions: Vec<String>,
pub diary: Vec<DiaryEntry>,
pub prose: Vec<Prose>,
pub state: Vec<StateLine>,
pub warnings: Vec<String>,
pub documents: Vec<Document>,
pub wishes: Vec<String>,
pub plan_is_generated: bool,
}
#[derive(Debug, Clone, Default)]
pub struct Document {
pub kind: String,
pub slug: String,
pub title: String,
pub body: String,
pub source_file: String,
}
pub const FILES: [&str; 5] = ["План.md", "Изменения.md", "README.md", "Решения.md", "Дневник.md"];
pub const DOCUMENT_FILES: [(&str, &str); 2] = [("Видение.md", "vision"), ("Ритуалы.md", "rituals")];
pub const RESEARCH_DIR: &str = "Исследования";
pub const WISHES_FILE: &str = "Хотелки.md";
pub fn looks_like_a_hub(dir: &Path) -> bool {
FILES.iter().any(|name| dir.join(name).is_file())
|| DOCUMENT_FILES.iter().any(|(name, _)| dir.join(name).is_file())
|| dir.join(RESEARCH_DIR).is_dir()
}
pub fn read(dir: &Path) -> Result<Hub> {
if !dir.is_dir() {
bail!("{} is not there; a hub that cannot be read is not an empty hub", dir.display());
}
if !looks_like_a_hub(dir) {
bail!(
"{} holds nothing a hub is read from - it is not a hub, and reading it as an empty one would strike every stage of the project",
dir.display()
);
}
let mut hub = Hub::default();
read_file(dir, "План.md", &mut hub, |text, hub| {
hub.plan_is_generated = crate::export::is_generated(text);
hub.questions = parse_questions(text);
hub.open_stages = parse_stages(text);
hub.prose.extend(parse_prose(text, "План.md"));
})?;
read_file(dir, "Изменения.md", &mut hub, |text, hub| {
hub.closed_stages = parse_stages(text);
hub.prose.extend(parse_prose(text, "Изменения.md"));
})?;
read_file(dir, "README.md", &mut hub, |text, hub| {
hub.state = parse_state(text);
hub.prose.extend(parse_prose(text, "README.md"));
})?;
warn_about_repeats(&mut hub);
read_file(dir, "Решения.md", &mut hub, |text, hub| {
hub.decisions = parse_decisions(text);
})?;
read_file(dir, "Дневник.md", &mut hub, |text, hub| {
hub.diary = parse_diary(text);
hub.prose.extend(parse_prose(text, "Дневник.md"));
})?;
if let Ok(text) = std::fs::read_to_string(dir.join(WISHES_FILE)) {
hub.wishes = parse_wishes(&text);
}
hub.documents = read_documents(dir);
Ok(hub)
}
fn read_documents(dir: &Path) -> Vec<Document> {
let mut out = Vec::new();
for (file, kind) in DOCUMENT_FILES {
if let Ok(text) = std::fs::read_to_string(dir.join(file))
&& !text.trim().is_empty()
{
out.push(document_from(kind, kind, &text, file));
}
}
if let Ok(text) = std::fs::read_to_string(dir.join("Решения.md")) {
let preamble = decisions_preamble(&text);
if !preamble.trim().is_empty() {
out.push(document_from("decisions", "decisions", &preamble, "Решения.md"));
}
}
if let Ok(entries) = std::fs::read_dir(dir.join(RESEARCH_DIR)) {
let mut notes: Vec<_> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "md"))
.collect();
notes.sort();
let mut seen: Vec<String> = Vec::new();
for path in notes {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
if text.trim().is_empty() {
continue;
}
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let mut slug = crate::db::slugify(&stem);
if slug.is_empty() {
slug = "research".to_string();
}
if seen.iter().any(|taken| taken == &slug) {
let base = slug.clone();
for n in 2.. {
let candidate = format!("{base}-{n}");
if !seen.iter().any(|taken| taken == &candidate) {
slug = candidate;
break;
}
}
}
seen.push(slug.clone());
let file = format!(
"{RESEARCH_DIR}/{}",
path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
);
out.push(document_from("research", &slug, &text, &file));
}
}
out
}
fn decisions_preamble(text: &str) -> String {
let mut out = String::new();
for line in text.lines() {
if line.starts_with("## ") {
break;
}
out.push_str(line);
out.push('\n');
}
out.trim().trim_end_matches('-').trim_end().to_string()
}
fn document_from(kind: &str, slug: &str, text: &str, source_file: &str) -> Document {
let title = text
.lines()
.find(|l| l.starts_with("# "))
.map(|l| l.trim_start_matches('#').trim().to_string())
.unwrap_or_else(|| slug.to_string());
Document {
kind: kind.to_string(),
slug: slug.to_string(),
title,
body: text.trim_end().to_string(),
source_file: source_file.to_string(),
}
}
fn read_file(dir: &Path, name: &str, hub: &mut Hub, parse: impl FnOnce(&str, &mut Hub)) -> Result<()> {
let path = dir.join(name);
match std::fs::read_to_string(&path) {
Ok(text) => {
parse(&text, hub);
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
hub.warnings.push(format!("{name} is missing from {}", dir.display()));
Ok(())
}
Err(e) => Err(e).with_context(|| format!("cannot read {}", path.display())),
}
}
fn looks_like_a_date(text: &str) -> bool {
let text = text.trim();
let mut digits = text.chars().take_while(|c| c.is_ascii_digit()).count();
if digits == 0 {
return false;
}
let rest = &text[digits..];
let Some(rest) = rest.strip_prefix(['-', '.', '/']) else { return false };
digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
digits > 0
}
fn parse_state(text: &str) -> Vec<StateLine> {
let mut out: Vec<StateLine> = Vec::new();
let mut inside = false;
let mut blank = 0usize;
for line in text.lines() {
if let Some((_, head)) = heading(line) {
inside = head.starts_with("Состояние");
continue;
}
if !inside {
continue;
}
if line.trim().is_empty() {
blank += 1;
continue;
}
let Some(rest) = line.trim().strip_prefix("- ") else { continue };
let line = match rest.strip_prefix("**").and_then(|r| r.split_once("**")) {
Some((stamp, body)) if looks_like_a_date(stamp) => StateLine {
stamp: Some(stamp.trim().to_string()),
body: body.trim_start().trim_start_matches(['—', '-', '–']).trim().to_string(),
gap_after: 0,
},
_ => StateLine {
stamp: None,
body: rest.trim().to_string(),
gap_after: 0,
},
};
if line.body.is_empty() {
continue;
}
if let Some(previous) = out.last_mut() {
previous.gap_after = blank;
}
blank = 0;
out.push(line);
}
out
}
fn warn_about_repeats(hub: &mut Hub) {
for (file, stages) in [("План.md", &hub.open_stages), ("Изменения.md", &hub.closed_stages)] {
let mut seen: Vec<&str> = Vec::new();
let mut said: Vec<&str> = Vec::new();
for stage in stages {
let version = stage.version.as_str();
if seen.contains(&version) && !said.contains(&version) {
said.push(version);
}
seen.push(version);
}
let said: Vec<String> = said.into_iter().map(str::to_string).collect();
for version in said {
hub.warnings.push(format!(
"{file} writes up {version} more than once; the record keeps one entry per version, so an export writes one"
));
}
}
}
#[derive(Default)]
struct Fence(bool);
impl Fence {
fn inside(&mut self, line: &str) -> bool {
let was = self.0;
if line.trim_start().starts_with("```") {
self.0 = !self.0;
}
was || self.0
}
}
fn heading(line: &str) -> Option<(usize, &str)> {
let hashes = line.len() - line.trim_start_matches('#').len();
if hashes == 0 || hashes > 6 {
return None;
}
let rest = line[hashes..].strip_prefix(' ')?;
Some((hashes, rest.trim()))
}
fn leading_version(text: &str) -> Option<&str> {
let word = text.split_whitespace().next()?;
let rest = word.strip_prefix('v')?;
let mut parts = rest.split('.');
let first = parts.next()?;
if first.is_empty() || !first.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
parts.next()?;
Some(word)
}
fn trailing_date(text: &str) -> Option<String> {
for word in text.split(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.')) {
let digits: Vec<&str> = word.split(['-', '.']).collect();
if digits.len() != 3 || !digits.iter().all(|p| p.bytes().all(|b| b.is_ascii_digit())) {
continue;
}
if digits[0].len() == 4 {
return Some(format!("{}-{}-{}", digits[0], digits[1], digits[2]));
}
if digits[2].len() == 4 {
return Some(format!("{}-{}-{}", digits[2], digits[1], digits[0]));
}
}
None
}
fn released_version(tail: &str) -> Option<&str> {
let at = tail.find("выпущен")?;
let rest = &tail[at..];
rest.split(|c: char| c.is_whitespace() || c == '*')
.filter_map(|word| leading_version(word).filter(|v| v.len() == word.len()))
.next()
}
fn stage_title(text: &str, version: &str) -> Option<String> {
let rest = text[version.len()..].trim();
let rest = rest.trim_start_matches(['·', '-', '—', ':']).trim();
let title = rest.split(['—', '–']).next().unwrap_or(rest).trim();
(!title.is_empty()).then(|| title.to_string())
}
fn parse_stages(text: &str) -> Vec<Stage> {
let mut stages: Vec<Stage> = Vec::new();
let mut level = 0usize;
let mut notes = String::new();
let runs = prose_positions(text);
for (at_line, line) in text.lines().enumerate() {
if let Some((depth, head)) = heading(line) {
match leading_version(head) {
Some(version) => {
settle(&mut stages, &mut notes);
let tail = &head[version.len()..];
stages.push(Stage {
version: released_version(tail).unwrap_or(version).to_string(),
title: stage_title(head, version),
shipped_on: trailing_date(tail),
tasks: Vec::new(),
notes: String::new(),
depth,
notes_after: String::new(),
heading: head.to_string(),
after_prose: runs[at_line],
gap_after: 1,
rank: stages.len(),
});
level = depth;
}
None if depth <= level => {
settle(&mut stages, &mut notes);
level = 0;
}
None if level > 0 => {
notes.push_str(line);
notes.push('\n');
}
None => {}
}
continue;
}
if level == 0 {
continue;
}
if let Some(task) = parse_task(line) {
if let Some(stage) = stages.last_mut() {
if stage.tasks.is_empty() && !notes.trim().is_empty() {
stage.notes = notes.trim().to_string();
notes.clear();
}
stage.tasks.push(task);
}
continue;
}
notes.push_str(line);
notes.push('\n');
}
settle(&mut stages, &mut notes);
stages
}
fn settle(stages: &mut [Stage], notes: &mut String) {
if !notes.trim().is_empty()
&& let Some(stage) = stages.last_mut()
{
let settled = notes.trim().to_string();
let at = notes.find(&settled).unwrap_or(0) + settled.len();
stage.gap_after = notes[at..].matches('\n').count().saturating_sub(1);
match stage.tasks.is_empty() && stage.notes.is_empty() {
true => stage.notes = settled,
false => stage.notes_after = settled,
}
}
notes.clear();
}
fn parse_diary(text: &str) -> Vec<DiaryEntry> {
let mut entries: Vec<DiaryEntry> = Vec::new();
let mut body = String::new();
for line in text.lines() {
if let Some((depth, head)) = heading(line) {
if depth <= 2
&& let Some(date) = trailing_date(head)
{
if let Some(last) = entries.last_mut() {
settle_entry(last, &body);
}
body.clear();
entries.push(DiaryEntry {
heading: Some(head.to_string()),
date,
body: String::new(),
followed_by_rule: false,
gap_after: 1,
rank: entries.len(),
});
continue;
}
}
if !entries.is_empty() {
body.push_str(line);
body.push('\n');
}
}
if let Some(last) = entries.last_mut() {
settle_entry(last, &body);
}
entries
}
fn settle_entry(entry: &mut DiaryEntry, body: &str) {
let trimmed = body.trim();
match trimmed.strip_suffix("---") {
Some(rest) => {
entry.body = rest.trim_end().to_string();
entry.followed_by_rule = true;
let after = &body[body.rfind("---").map(|at| at + 3).unwrap_or(body.len())..];
entry.gap_after = after.matches('\n').count().saturating_sub(1);
}
None => {
entry.body = trimmed.to_string();
let at = body.find(trimmed).unwrap_or(0) + trimmed.len();
entry.gap_after = body[at..].matches('\n').count().saturating_sub(1);
}
}
}
fn parse_prose(text: &str, file: &str) -> Vec<Prose> {
prose_and_positions(text, file).0
}
fn prose_positions(text: &str) -> Vec<usize> {
prose_and_positions(text, "").1
}
fn prose_and_positions(text: &str, file: &str) -> (Vec<Prose>, Vec<usize>) {
let mut runs: Vec<Prose> = Vec::new();
let mut positions: Vec<usize> = Vec::new();
let mut body = String::new();
let mut heading_now: Option<String> = None;
let mut inside_entry = false;
let mut entry_depth = 0usize;
let mut fence = Fence::default();
fn flush(heading: Option<String>, body: &mut String, runs: &mut Vec<Prose>, file: &str) {
let text = body.trim();
let gap_after = body[text.len() + body.find(text).unwrap_or(0)..].matches('\n').count().saturating_sub(1);
if !text.is_empty() || heading.is_some() {
runs.push(Prose {
gap_after,
file: file.to_string(),
position: runs.len(),
heading,
body: text.to_string(),
});
}
body.clear();
}
for line in text.lines() {
positions.push(runs.len() + usize::from(heading_now.is_some() || !body.trim().is_empty()));
if line.trim() == crate::export::MARK {
continue;
}
let fenced = fence.inside(line);
if let Some((depth, head)) = heading(line).filter(|_| !fenced) {
let is_diary = file.contains("Дневник");
let starts_entry = leading_version(head).is_some() || (is_diary && depth <= 2 && trailing_date(head).is_some());
if starts_entry {
if !inside_entry {
flush(heading_now.take(), &mut body, &mut runs, file);
}
inside_entry = true;
entry_depth = depth;
heading_now = None;
body.clear();
continue;
}
if inside_entry && depth > entry_depth {
continue;
}
if !inside_entry {
flush(heading_now.take(), &mut body, &mut runs, file);
}
inside_entry = false;
heading_now = Some(line.to_string());
body.clear();
continue;
}
if !inside_entry {
body.push_str(line);
body.push('\n');
}
}
if !inside_entry {
flush(heading_now.take(), &mut body, &mut runs, file);
}
(runs, positions)
}
fn parse_task(line: &str) -> Option<Task> {
let rest = line.trim_start();
let rest = rest.strip_prefix("- ").or_else(|| rest.strip_prefix("* "))?;
let (mark, title) = rest.split_at(rest.char_indices().nth(3).map(|(i, _)| i)?);
let done = match mark {
"[ ]" => false,
"[x]" | "[X]" => true,
_ => return None,
};
let title = title.trim();
(!title.is_empty()).then(|| Task {
title: title.to_string(),
done,
})
}
pub fn parse_wishes(text: &str) -> Vec<String> {
let mut wishes: Vec<String> = Vec::new();
let mut block: Vec<&str> = Vec::new();
let mut started = false;
fn flush(block: &mut Vec<&str>, wishes: &mut Vec<String>) {
let joined = block.join(
"
",
);
let trimmed = joined.trim();
let bare = trimmed
.trim_start_matches(["- ", "* "].iter().find(|l| trimmed.starts_with(**l)).copied().unwrap_or(""))
.trim_matches(['_', '*'])
.trim();
if !trimmed.is_empty() && !is_placeholder(bare) && !is_settled(bare) {
wishes.push(trimmed.to_string());
}
block.clear();
}
for line in text.lines() {
let trimmed = line.trim();
if let Some((level, _)) = heading(line) {
flush(&mut block, &mut wishes);
started |= level > 1;
continue;
}
if trimmed == "---" {
flush(&mut block, &mut wishes);
started = true;
continue;
}
if !started {
continue;
}
if opens_a_wish(trimmed) && !block.is_empty() {
flush(&mut block, &mut wishes);
}
if trimmed.is_empty() && block.is_empty() {
continue;
}
block.push(line);
}
flush(&mut block, &mut wishes);
wishes
}
fn parse_questions(text: &str) -> Vec<String> {
let mut questions = Vec::new();
let mut inside = false;
for line in text.lines() {
if let Some((_, head)) = heading(line) {
inside = head.starts_with("Ждёт решения владельца");
continue;
}
if !inside {
continue;
}
let trimmed = line.trim();
let item = ["- [ ] ", "- [x] ", "- [X] ", "- ", "* "]
.iter()
.find_map(|lead| trimmed.strip_prefix(lead))
.or_else(|| {
trimmed
.split_once(". ")
.filter(|(n, _)| n.bytes().all(|b| b.is_ascii_digit()))
.map(|(_, rest)| rest)
});
if let Some(item) = item {
let item = item.trim();
if !item.is_empty() && !is_placeholder(item) {
questions.push(item.to_string());
}
}
}
questions
}
fn opens_a_wish(line: &str) -> bool {
let Some(rest) = line.strip_prefix("**") else { return false };
let Some((bold, _)) = rest.split_once("**") else { return false };
bold.split_whitespace().next().is_some_and(|word| {
let digits = word.chars().filter(char::is_ascii_digit).count();
digits >= 4 && word.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '/')
})
}
fn is_settled(block: &str) -> bool {
let head = block.trim_start_matches(['*', '_', ' ']);
head.starts_with("Разобрано") || head.starts_with("Пусто")
}
fn is_placeholder(item: &str) -> bool {
const PLACEHOLDERS: [&str; 6] = ["(пусто)", "(нет)", "(none)", "(empty)", "—", "-"];
let head = item.split(['—', '–']).next().unwrap_or(item).trim();
PLACEHOLDERS.contains(&head) || PLACEHOLDERS.contains(&item)
}
fn parse_decisions(text: &str) -> Vec<Decision> {
let mut decisions: Vec<Decision> = Vec::new();
let mut body = String::new();
for line in text.lines() {
if let Some((_, head)) = heading(line) {
if let Some(last) = decisions.last_mut() {
last.body = body.trim().to_string();
}
body.clear();
if let Some(date) = trailing_date(head) {
let title = head
.split_once(['·', '—'])
.map(|(_, t)| t.trim())
.filter(|t| !t.is_empty())
.unwrap_or(head)
.to_string();
decisions.push(Decision {
date,
title,
body: String::new(),
});
}
continue;
}
if !decisions.is_empty() && line.trim() != "---" {
body.push_str(line);
body.push('\n');
}
}
if let Some(last) = decisions.last_mut() {
last.body = body.trim().to_string();
}
decisions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_version_heading_starts_a_stage_at_any_level() {
let stages = parse_stages("# Plan\n### v0.4.0 · Title\n- [ ] one\n## v0.5.0 · Next\n- [x] two\n");
assert_eq!(stages.len(), 2);
assert_eq!(stages[0].version, "v0.4.0");
assert_eq!(stages[0].title.as_deref(), Some("Title"));
assert_eq!(
stages[0].tasks,
vec![Task {
title: "one".into(),
done: false
}]
);
assert_eq!(
stages[1].tasks,
vec![Task {
title: "two".into(),
done: true
}]
);
}
#[test]
fn a_heading_at_or_above_the_stage_level_ends_it() {
let stages = parse_stages("## v0.1.0 · A\n- [ ] real\n## Бэклог без версии\n- [ ] someday\n");
assert_eq!(stages.len(), 1);
assert_eq!(stages[0].tasks.len(), 1);
}
#[test]
fn a_deeper_heading_does_not_end_a_stage() {
let stages = parse_stages("## v0.1.0 · A\n- [ ] before\n### Подробности\n- [ ] after\n");
assert_eq!(stages[0].tasks.len(), 2);
}
#[test]
fn the_release_note_is_not_part_of_the_title() {
let stages = parse_stages("## v0.2.0 · Три формы и лесенка — выпущена 2026-09-03\n");
assert_eq!(stages[0].title.as_deref(), Some("Три формы и лесенка"));
assert_eq!(stages[0].shipped_on.as_deref(), Some("2026-09-03"));
}
#[test]
fn every_wording_of_a_closed_stage_yields_its_date() {
for head in [
"## v1.0.0 · A — закрыт 2026-08-12",
"## v1.0.0 · A — выпущен 2026-08-12",
"## v1.0.0 · A — выпущена 2026-08-12",
"## v1.0.0 · A — 2026-08-12",
"## v1.0.0 · A — 12.08.2026",
] {
let stages = parse_stages(head);
assert_eq!(stages[0].shipped_on.as_deref(), Some("2026-08-12"), "{head}");
}
}
#[test]
fn a_stage_takes_the_number_it_shipped_as() {
let stages = parse_stages("## v1.9 · Очередь и backfill — закрыт 2026-09-03, выпущен **v1.9.0**\n");
assert_eq!(stages[0].version, "v1.9.0");
assert_eq!(stages[0].title.as_deref(), Some("Очередь и backfill"));
assert_eq!(stages[0].shipped_on.as_deref(), Some("2026-09-03"));
}
#[test]
fn a_stage_without_a_separate_release_keeps_its_own_number() {
let stages = parse_stages("## v0.2.0 · Три формы — выпущена 2026-09-03\n");
assert_eq!(stages[0].version, "v0.2.0");
}
#[test]
fn an_open_stage_has_no_date() {
let stages = parse_stages("## v0.9.0 · Inbox and digest\n- [ ] a\n");
assert_eq!(stages[0].shipped_on, None);
}
#[test]
fn headings_without_a_version_are_not_stages() {
let stages = parse_stages("## S118 · Транскрипт (2026-08-06)\n- [ ] a\n## Блок «Читаю»\n");
assert!(stages.is_empty());
}
#[test]
fn a_bare_major_in_prose_is_not_a_version() {
assert_eq!(leading_version("v2 is the goal"), None);
assert_eq!(leading_version("v0.4.0 · Title"), Some("v0.4.0"));
}
#[test]
fn questions_are_read_until_the_next_heading() {
let text = "# План\n\n## Ждёт решения владельца\n\n1. First thing.\n- Second thing.\n\n## Мажорная карта\n\n- not a question\n";
assert_eq!(parse_questions(text), vec!["First thing.", "Second thing."]);
}
#[test]
fn the_placeholder_is_not_a_question() {
assert!(parse_questions("## Ждёт решения владельца\n\n- (пусто)\n").is_empty());
}
#[test]
fn decisions_keep_their_prose() {
let text = "# Журнал решений\n\n---\n\n## 2026-09-03 · Что на экране, то и оценивается\n\nBody line one.\n\nBody line two.\n\n## 2026-09-02 · Основание\n\nOnly line.\n";
let decisions = parse_decisions(text);
assert_eq!(decisions.len(), 2);
assert_eq!(decisions[0].date, "2026-09-03");
assert_eq!(decisions[0].title, "Что на экране, то и оценивается");
assert_eq!(decisions[0].body, "Body line one.\n\nBody line two.");
assert_eq!(decisions[1].body, "Only line.");
}
#[test]
fn an_undated_heading_is_not_a_decision() {
assert!(parse_decisions("## Журнал\n\nprose\n").is_empty());
}
#[test]
fn a_stage_keeps_the_prose_that_followed_its_tasks() {
let text = "# План
# Блок A
## v0.1.0 · One
- [ ] task one
**Результат:** первое.
# Блок B
## v0.2.0 · Two
- [ ] task two
**Результат:** второе.
";
let stages = parse_stages(text);
assert_eq!(stages.len(), 2);
assert!(stages[0].notes_after.contains("первое"), "{:?}", stages[0]);
assert!(stages[1].notes_after.contains("второе"), "{:?}", stages[1]);
assert!(stages[0].notes.is_empty() && stages[1].notes.is_empty(), "nothing opened either stage");
}
#[test]
fn stages_of_one_block_keep_the_order_they_were_written_in() {
let text = "# Изменения\n\n## v2.2.0 · Third\n\n## v2.1.0 · Second\n\n## v2.0.0 · First\n";
let stages = parse_stages(text);
let order: Vec<&str> = stages.iter().map(|s| s.version.as_str()).collect();
assert_eq!(order, ["v2.2.0", "v2.1.0", "v2.0.0"]);
assert!(stages.iter().all(|s| s.after_prose == stages[0].after_prose), "one block");
assert_eq!(stages.iter().map(|s| s.rank).collect::<Vec<_>>(), [0, 1, 2]);
}
#[test]
fn the_gap_after_a_rule_is_the_one_the_diary_wrote() {
let diary = |gap: &str| {
let text = format!("# Дневник\n\n## 2026-09-05 · Later\n\nЧто делали.\n\n---\n{gap}## 2026-09-04 · Earlier\n\nРаньше.\n");
parse_diary(&text)
};
let one = diary("\n");
assert!(one[0].followed_by_rule, "the rule is the separator, not the body");
assert_eq!(one[0].gap_after, 1);
assert_eq!(diary("\n\n")[0].gap_after, 2);
assert_eq!(diary("")[0].gap_after, 0);
assert!(!one[0].body.contains("---"), "{:?}", one[0].body);
}
#[test]
fn a_stage_keeps_the_gap_its_hub_left_after_it() {
let stage = |gap: &str| {
let text = format!("# Изменения\n\n## v0.2.0 · Second\n\nПро второй.\n\n---\n{gap}## v0.1.0 · First\n\nПро первый.\n");
parse_stages(&text)
};
assert_eq!(stage("\n")[0].gap_after, 1);
assert_eq!(stage("\n\n")[0].gap_after, 2);
assert_eq!(stage("")[0].gap_after, 0);
assert!(stage("\n")[0].notes.ends_with("---"), "{:?}", stage("\n")[0].notes);
}
#[test]
fn a_version_written_up_twice_is_warned_about() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("План.md"), "# План\n").unwrap();
std::fs::write(
dir.path().join("Изменения.md"),
"# Изменения\n\n## v0.1.0 · First — выпущен 2026-08-14\n\nОдин рассказ.\n\n## v0.1.0 · First — выпущен 2026-08-14\n\nДругой рассказ.\n",
)
.unwrap();
std::fs::write(dir.path().join("Дневник.md"), "# Дневник\n").unwrap();
let hub = read(dir.path()).unwrap();
assert!(
hub.warnings.iter().any(|w| w.contains("v0.1.0") && w.contains("more than once")),
"{:?}",
hub.warnings
);
}
#[test]
fn versions_written_once_raise_nothing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("План.md"), "# План\n").unwrap();
std::fs::write(
dir.path().join("Изменения.md"),
"# Изменения\n\n## v0.2.0 · Second — выпущен 2026-08-15\n\nПро второй.\n\n## v0.1.0 · First — выпущен 2026-08-14\n\nПро первый.\n",
)
.unwrap();
std::fs::write(dir.path().join("Дневник.md"), "# Дневник\n").unwrap();
let hub = read(dir.path()).unwrap();
assert!(!hub.warnings.iter().any(|w| w.contains("more than once")), "{:?}", hub.warnings);
}
#[test]
fn a_heading_inside_a_stage_belongs_to_the_stage() {
let text = "# Изменения\n\n## v0.2.0 · Second\n\nПро второй.\n\n### Патч v0.2.1\n\nПро патч.\n\n## v0.1.0 · First\n\nПро первый.\n";
let stages = parse_stages(text);
assert_eq!(stages.len(), 2, "the deeper heading is not a stage of its own");
assert!(stages[0].notes.contains("### Патч v0.2.1"), "the heading is kept: {:?}", stages[0].notes);
assert!(stages[0].notes.contains("Про патч."), "and so is what it introduced");
let runs = parse_prose(text, "Изменения.md");
assert!(
!runs
.iter()
.any(|r| r.heading.as_deref().is_some_and(|h| h.contains("Патч")) || r.body.contains("Про патч.")),
"{runs:?}"
);
}
#[test]
fn diary_entries_keep_the_place_they_had() {
let text =
"# Дневник\n\n## 2026-08-31 (2) · Later that day\n\nПотом.\n\n## 2026-08-31 · Earlier\n\nСначала.\n\n## 2026-08-30 · The day before\n\nНакануне.\n";
let entries = parse_diary(text);
assert_eq!(entries.iter().map(|e| e.rank).collect::<Vec<_>>(), [0, 1, 2]);
assert_eq!(entries[0].date, entries[1].date);
assert!(entries[0].heading.as_deref().is_some_and(|h| h.contains("(2)")), "{:?}", entries[0].heading);
}
#[test]
fn a_bold_sentence_is_not_the_date_of_a_state_line() {
let text = "# Хаб\n\n## Состояние\n\n- **2026-09-05 (ночь)** — что-то случилось\n- **Кода пока нет намеренно.** Хаб заведён авансом.\n- Основание — см. Изменения.\n";
let state = parse_state(text);
assert_eq!(state.len(), 3);
assert_eq!(state[0].stamp.as_deref(), Some("2026-09-05 (ночь)"));
assert_eq!(state[0].body, "что-то случилось");
assert_eq!(state[1].stamp, None);
assert_eq!(state[1].body, "**Кода пока нет намеренно.** Хаб заведён авансом.");
assert_eq!(state[2].stamp, None);
}
#[test]
fn a_state_line_keeps_the_gap_that_followed_it() {
let text = "## Состояние\n\n- **2026-09-05** — новее\n\n- **2026-09-04** — старее\n- **2026-09-03** — ещё старее\n";
let state = parse_state(text);
assert_eq!(state.iter().map(|l| l.gap_after).collect::<Vec<_>>(), [1, 0, 0]);
}
#[test]
fn a_hash_inside_a_fence_is_not_a_heading() {
let text = "# Хаб\n\n## Запуск\n\n```\ncargo build\n# и потом\n```\n\nПосле блока.\n";
let runs = parse_prose(text, "README.md");
let all: String = runs.iter().map(|r| r.body.as_str()).collect::<Vec<_>>().join("\n");
assert!(all.contains("# и потом"), "the comment is kept: {all:?}");
assert!(all.contains("cargo build"), "{all:?}");
assert!(!runs.iter().any(|r| r.heading.as_deref() == Some("# и потом")), "{runs:?}");
}
#[test]
fn a_missing_file_is_a_warning_not_a_failure() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("План.md"), "## v0.1.0 · A\n- [ ] task\n").unwrap();
let hub = read(dir.path()).unwrap();
assert_eq!(hub.open_stages.len(), 1);
let missing: Vec<&str> = ["Изменения.md", "Решения.md", "Дневник.md"]
.into_iter()
.filter(|name| !hub.warnings.iter().any(|w| w.contains(name)))
.collect();
assert!(missing.is_empty(), "not warned about: {missing:?} in {:?}", hub.warnings);
assert!(!hub.warnings.iter().any(|w| w.contains("План.md")), "{:?}", hub.warnings);
}
}