Skip to main content

mbr_core/
flashcards.rs

1//! Flashcard review history: the pure half of `POST /.mbr/flashcard-review`.
2//!
3//! A note with `type: flashcard` turns its definition lists into a deck — each
4//! term is a card's front, its definitions the back. Spaced-repetition results
5//! are kept *in the note itself*, as one more definition of the term:
6//!
7//! ```markdown
8//! What is the capital of France?
9//! : Paris.
10//! : ___Review History___
11//!   * 2026-10-06 13:45 - Again
12//!   * 2026-10-09 18:02 - Good
13//! ```
14//!
15//! This module appends one entry to that list (creating the definition when the
16//! term has none yet). Like [`crate::tasks::patch_task_line`] it knows nothing
17//! about the filesystem or HTTP: the handler reads the file, passes the text and
18//! the reviewer's time in, and writes back [`ReviewPatch::source`].
19//!
20//! # Whose clock
21//!
22//! The entry has no offset (the format is the user's, and it has to read well
23//! as plain text), and the deck replays it as the *browser's* local time. So
24//! the time must be the reviewer's wall clock, not the server's: stamped by a
25//! server in another time zone, a review would land hours away from where the
26//! deck expects it and FSRS's 1-minute/10-minute learning steps would come due
27//! at the wrong moment or not at all. The client therefore sends its local time
28//! and [`parse_review_time`] checks it — the exact entry format, and within
29//! [`REVIEW_TIME_WINDOW_HOURS`] of the server's UTC clock.
30//!
31//! # Why a real parse, not a line scan
32//!
33//! Where a term's definitions end is a markdown question, not a textual one: a
34//! `: not a definition` line inside a fenced code block, a lazy continuation, a
35//! nested list in an answer and a loose list's blank lines all defeat a regex.
36//! pulldown-cmark's offset iterator answers it with the same options the
37//! renderer uses ([`markdown_options`]), so the writer and the page cannot
38//! disagree about which `<dd>`s belong to which `<dt>`.
39//!
40//! # Scope: top-level definition lists only
41//!
42//! A definition list nested in a block quote or list item would need every
43//! inserted line to repeat the container's `> ` / indentation prefix. The deck
44//! (`components/src/flashcards/dom.ts`) collects top-level lists only, and this
45//! module refuses anything else with [`FlashcardPatchError::NotATerm`], so the
46//! two agree on what a card is.
47
48use std::ops::Range;
49
50use chrono::NaiveDateTime;
51use pulldown_cmark::{Event, Parser, Tag, TagEnd};
52use serde::{Deserialize, Serialize};
53
54use crate::errors::{FlashcardPatchError, ReviewTimeError};
55use crate::markdown::{LineIndex, markdown_options, strip_bom};
56use crate::tasks::{line_span, split_line_terminator};
57
58/// The label written when a term gets its first review.
59///
60/// `___x___` renders as `<em><strong>x</strong></em>`. Any emphasis wrapping the
61/// words "Review History" (case-insensitive) is *recognised* — see
62/// [`is_history_label`] — but this is the one spelling mbr writes.
63pub const HISTORY_LABEL: &str = "___Review History___";
64
65/// The label's words, compared case-insensitively once emphasis is stripped.
66const HISTORY_TEXT: &str = "review history";
67
68/// `YYYY-MM-DD HH:MM`, the reviewer's local wall-clock time without an offset
69/// — the same shape as a task's `@done(...)` stamp.
70const ENTRY_TIME_FORMAT: &str = "%Y-%m-%d %H:%M";
71
72/// How far a reviewer's wall clock may sit from the server's UTC clock.
73///
74/// Real offsets run from UTC−12 to UTC+14, so every reviewer is within 14
75/// hours; 26 leaves room for a badly-set clock on either side while still
76/// refusing a stray epoch, a far-future date or a client that sent something
77/// other than "now".
78pub const REVIEW_TIME_WINDOW_HOURS: i64 = 26;
79
80/// Validates the reviewer's local time as sent by the deck (`at`).
81///
82/// Strictly `YYYY-MM-DD HH:MM` — ASCII digits, zero-padded, no seconds, no
83/// offset, nothing around it — because the string is written into the note
84/// verbatim and has to read back as an entry. Chrono's own parser would accept
85/// `2026-1-6 9:05` and a leading `+`, so the shape is checked byte by byte
86/// first and chrono only judges the calendar (month 13, February 29th of a
87/// common year, `24:00`, minute 60 — there is no minute-precision spelling of a
88/// leap second).
89///
90/// `now_utc` is the server's clock as a naive UTC time, a parameter so the
91/// window is testable without mocking it.
92///
93/// # Errors
94///
95/// [`ReviewTimeError::Malformed`] for anything that is not a real date and
96/// time in the entry format; [`ReviewTimeError::OutOfRange`] for one more than
97/// [`REVIEW_TIME_WINDOW_HOURS`] away from `now_utc`.
98pub fn parse_review_time(
99    at: &str,
100    now_utc: NaiveDateTime,
101) -> Result<NaiveDateTime, ReviewTimeError> {
102    const SHAPE: &[u8; 16] = b"0000-00-00 00:00";
103    let shaped = at.len() == SHAPE.len()
104        && at.bytes().zip(SHAPE).all(|(byte, &want)| match want {
105            b'0' => byte.is_ascii_digit(),
106            separator => byte == separator,
107        });
108    if !shaped {
109        return Err(ReviewTimeError::Malformed);
110    }
111    let time = NaiveDateTime::parse_from_str(at, ENTRY_TIME_FORMAT)
112        .map_err(|_| ReviewTimeError::Malformed)?;
113    let window = chrono::TimeDelta::hours(REVIEW_TIME_WINDOW_HOURS);
114    if (time - now_utc).abs() > window {
115        return Err(ReviewTimeError::OutOfRange);
116    }
117    Ok(time)
118}
119
120/// One self-rating, in Anki's four-button vocabulary.
121///
122/// Readers also accept `Fail` as a synonym for [`Rating::Again`] (that is the
123/// frontend's job — it parses the history), but mbr only ever *writes* `Again`.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
125#[serde(rename_all = "lowercase")]
126pub enum Rating {
127    Again,
128    Hard,
129    Good,
130    Easy,
131}
132
133impl Rating {
134    /// How the rating is spelled in a history entry.
135    pub fn label(self) -> &'static str {
136        match self {
137            Self::Again => "Again",
138            Self::Hard => "Hard",
139            Self::Good => "Good",
140            Self::Easy => "Easy",
141        }
142    }
143}
144
145/// The text of one history entry: `2026-10-06 13:45 - Good`.
146pub fn format_entry(reviewed_at: NaiveDateTime, rating: Rating) -> String {
147    format!(
148        "{} - {}",
149        reviewed_at.format(ENTRY_TIME_FORMAT),
150        rating.label()
151    )
152}
153
154/// A markdown source with one review appended by [`append_review`].
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ReviewPatch {
157    /// The whole file, ready to be written back.
158    pub source: String,
159    /// The entry text, as written (without the bullet).
160    pub entry: String,
161    /// 1-based line number of the first inserted line.
162    pub inserted_at: u32,
163    /// The inserted lines, without terminators: the bullet alone, or the
164    /// history label followed by the bullet when the term had no history yet —
165    /// either one followed by a blank line when the next card's term was glued
166    /// to the insert point (see [`append_review`]).
167    ///
168    /// Returned so a client holding the file's lines can splice them in rather
169    /// than re-read the file, and shift every later line number it holds.
170    pub inserted: Vec<String>,
171}
172
173impl ReviewPatch {
174    /// 1-based line number of the new bullet: the last non-blank inserted line.
175    pub fn entry_line(&self) -> u32 {
176        let index = self
177            .inserted
178            .iter()
179            .rposition(|line| !line.is_empty())
180            .unwrap_or(0);
181        self.inserted_at
182            .saturating_add(u32::try_from(index).unwrap_or(0))
183    }
184}
185
186/// Appends one review entry to the history of the term on `term_line`.
187///
188/// `reviewed_at` is the reviewer's local wall-clock time (see [`parse_review_time`]),
189/// written as the entry's timestamp.
190///
191/// `term_line` is 1-based — the `data-mbr-line` the renderer puts on the
192/// `<dt>`. `expected` is the line's text as the client last saw it, compared
193/// modulo its terminator exactly like `POST /.mbr/task`'s, so an edit to the
194/// term since the page was loaded is a conflict rather than a review recorded
195/// against the wrong card.
196///
197/// Where the entry goes:
198///
199/// * the term has a history definition with a list — a new item right after
200///   the list's last item, copying that item's indentation, marker and
201///   spacing (so `-` lists stay `-` lists);
202/// * a history definition with no list yet — a `* ` item at the definition's
203///   content indentation, right after it;
204/// * no history definition — `: ___Review History___` plus the item, right
205///   after the term's last definition, copying that definition's `: ` prefix
206///   (see [`definition_prefix`] for when it is not copied verbatim).
207///
208/// Every other byte is untouched: inserted lines use the terminator of the
209/// line they follow (so CRLF files stay CRLF), and a file without a trailing
210/// newline still has none.
211///
212/// # A term glued to the insert point
213///
214/// A term needs no blank line before it when the definition above ends in a
215/// block that cannot be lazily continued — a closed code fence:
216///
217/// ~~~markdown
218/// Q1
219/// : A1
220///
221///   ```
222///   code
223///   ```
224/// Q2
225/// : A2
226/// ~~~
227///
228/// Here `Q2` is a card, and so it is indented by up to three spaces. A history
229/// bullet inserted above it would give it a paragraph to continue, and the
230/// card would silently merge into `Q1`'s history. So when the line after the
231/// insert point is non-blank and not a `:` definition, a blank line follows
232/// the entry, whatever its indentation. (The common shape, `Q2` straight after
233/// a one-line answer, needs nothing: that `Q2` was a lazy continuation of the
234/// answer before the write, never a card.)
235///
236/// # Verified, not predicted
237///
238/// That rule is a prediction of how the parser will read the result, so the
239/// result is re-parsed: every term must still be a term on its (shifted) line,
240/// and the new entry must be the last item of the target's history. A patch
241/// that fails is retried with the separating blank line; if that fails too the
242/// review is refused rather than written.
243///
244/// # Errors
245///
246/// [`FlashcardPatchError`]. [`FlashcardPatchError::UnsafeInsert`] when no
247/// insertion passes the check above; every other variant means the client is
248/// looking at a stale copy of the file.
249///
250/// # Examples
251///
252/// ```
253/// use chrono::NaiveDate;
254/// use mbr_core::flashcards::{Rating, append_review};
255///
256/// let reviewed_at = NaiveDate::from_ymd_opt(2026, 10, 6)
257///     .and_then(|d| d.and_hms_opt(13, 45, 0))
258///     .unwrap();
259/// let source = "Capital of France?\n: Paris.\n";
260/// let patch = append_review(source, 1, "Capital of France?", Rating::Good, reviewed_at).unwrap();
261/// assert_eq!(
262///     patch.source,
263///     "Capital of France?\n: Paris.\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n"
264/// );
265/// assert_eq!(patch.inserted_at, 3);
266/// assert_eq!(patch.entry_line(), 4);
267/// ```
268pub fn append_review(
269    source: &str,
270    term_line: u32,
271    expected: &str,
272    rating: Rating,
273    reviewed_at: NaiveDateTime,
274) -> Result<ReviewPatch, FlashcardPatchError> {
275    let span = line_span(source, term_line)
276        .ok_or(FlashcardPatchError::LineOutOfRange { line: term_line })?;
277    let (content, term_terminator) = split_line_terminator(&source[span]);
278    if content != split_line_terminator(expected).0 {
279        return Err(FlashcardPatchError::Mismatch { line: term_line });
280    }
281
282    let not_a_term = FlashcardPatchError::NotATerm { line: term_line };
283    let terms = scan_terms(source);
284    let term = terms
285        .iter()
286        .find(|term| term.line == term_line)
287        .ok_or(not_a_term)?;
288
289    let lines = LineIndex::build(source);
290    let entry = format_entry(reviewed_at, rating);
291    let (after_line, inserted) = match term.definitions.iter().find(|d| d.history) {
292        Some(Definition {
293            last_item: Some(item),
294            ..
295        }) => (
296            last_line_of(source, &lines, item),
297            vec![bullet_after(source, item.start, &entry)],
298        ),
299        Some(history) => {
300            let indent = content_indent(&definition_prefix(source, history.range.start));
301            (
302                last_line_of(source, &lines, &history.range),
303                vec![format!("{indent}* {entry}")],
304            )
305        }
306        None => {
307            // A title is only ever emitted with at least one definition, so
308            // this cannot fail on a list pulldown-cmark produced.
309            let last = term.definitions.last().ok_or(not_a_term)?;
310            let prefix = definition_prefix(source, last.range.start);
311            (
312                last_line_of(source, &lines, &last.range),
313                vec![
314                    format!("{prefix}{HISTORY_LABEL}"),
315                    format!("{}* {entry}", content_indent(&prefix)),
316                ],
317            )
318        }
319    };
320
321    let glued = line_span(source, after_line.saturating_add(1))
322        .map(|span| split_line_terminator(&source[span]).0)
323        .is_some_and(could_be_absorbed);
324    let fallback_terminator = if term_terminator.is_empty() {
325        "\n"
326    } else {
327        term_terminator
328    };
329    let build = |separate: bool| {
330        let inserted: Vec<String> = inserted
331            .iter()
332            .cloned()
333            .chain(separate.then(String::new))
334            .collect();
335        ReviewPatch {
336            source: insert_lines_after(source, after_line, &inserted, fallback_terminator),
337            entry: entry.clone(),
338            inserted_at: after_line.saturating_add(1),
339            inserted,
340        }
341    };
342
343    // Defence in depth: the shape rules above are a prediction of how the
344    // parser will read the result. Check it with the parser itself, and fall
345    // back to the separating blank line before refusing outright.
346    let term_lines: Vec<u32> = terms.iter().map(|term| term.line).collect();
347    std::iter::once(glued)
348        .chain((!glued).then_some(true))
349        .map(build)
350        .find(|patch| keeps_every_card(patch, &term_lines, term_line))
351        .ok_or(FlashcardPatchError::UnsafeInsert { line: term_line })
352}
353
354/// True when `line`, directly after the insert point, could be swallowed by
355/// the new entry: anything non-blank that is not a `:` definition (which
356/// continues the definition list as it should).
357///
358/// Indentation does not matter. A line below the entry's content column is a
359/// lazy continuation of its paragraph; one at or past it is an ordinary
360/// continuation. Either way the next card's term — which may be indented up to
361/// three spaces — would stop being a term.
362fn could_be_absorbed(line: &str) -> bool {
363    let content = line.trim_start_matches([' ', '\t']);
364    !content.is_empty() && !content.starts_with(':')
365}
366
367/// Whether `patch` re-parses as the same deck plus one entry: every term is
368/// still a term (on its old line, shifted past the insert), and the new entry
369/// is the last item of `term_line`'s history definition — the one the next
370/// review will append to.
371fn keeps_every_card(patch: &ReviewPatch, term_lines: &[u32], term_line: u32) -> bool {
372    let shift = u32::try_from(patch.inserted.len()).unwrap_or(u32::MAX);
373    let after = scan_terms(&patch.source);
374    let moved = term_lines.iter().map(|&line| {
375        if line >= patch.inserted_at {
376            line.saturating_add(shift)
377        } else {
378            line
379        }
380    });
381    if !after.iter().map(|term| term.line).eq(moved) {
382        return false;
383    }
384    let Some(span) = line_span(&patch.source, patch.entry_line()) else {
385        return false;
386    };
387    // The entry's last character, which only its own list item can contain.
388    let text = split_line_terminator(&patch.source[span.clone()])
389        .0
390        .trim_end();
391    let Some(entry_end) = (span.start + text.len()).checked_sub(1) else {
392        return false;
393    };
394    after
395        .iter()
396        .find(|term| term.line == term_line)
397        .and_then(|term| term.definitions.iter().find(|d| d.history))
398        .and_then(|history| history.last_item.as_ref())
399        .is_some_and(|item| item.contains(&entry_end))
400}
401
402/// True when inline `text` — the words inside the emphasis that opens a
403/// definition — names the review-history definition.
404pub fn is_history_label(text: &str) -> bool {
405    text.trim().eq_ignore_ascii_case(HISTORY_TEXT)
406}
407
408// ============================================================================
409// Scanning
410// ============================================================================
411
412/// One top-level definition-list term and its definitions.
413#[derive(Debug, Clone, PartialEq, Eq)]
414struct Term {
415    /// 1-based line the term starts on.
416    line: u32,
417    definitions: Vec<Definition>,
418}
419
420/// One `<dd>`, as byte ranges into the source.
421#[derive(Debug, Clone, PartialEq, Eq)]
422struct Definition {
423    range: Range<usize>,
424    /// Whether this is the review-history definition.
425    history: bool,
426    /// The last item of the definition's first directly-nested list.
427    last_item: Option<Range<usize>>,
428}
429
430/// Whether a definition's leading content names it the history definition.
431#[derive(Debug)]
432enum Label {
433    /// Nothing but block wrappers seen yet.
434    Pending,
435    /// Inside the leading emphasis opened at `depth`, collecting its text.
436    Capturing {
437        depth: usize,
438        text: String,
439    },
440    Decided(bool),
441}
442
443/// Depth of a definition's own content: `DefinitionList` (0) →
444/// `DefinitionListDefinition` (1) → content (2).
445const CONTENT_DEPTH: usize = 2;
446
447/// A definition being walked.
448#[derive(Debug)]
449struct DefinitionScan {
450    range: Range<usize>,
451    label: Label,
452    /// Depth of the first list that is a direct child of the definition.
453    list_depth: Option<usize>,
454    list_closed: bool,
455    last_item: Option<Range<usize>>,
456}
457
458impl DefinitionScan {
459    fn new(range: Range<usize>) -> Self {
460        Self {
461            range,
462            label: Label::Pending,
463            list_depth: None,
464            list_closed: false,
465            last_item: None,
466        }
467    }
468
469    /// A `Start` at `depth` (the depth *outside* the tag being opened).
470    fn start(&mut self, tag: &Tag<'_>, depth: usize, range: &Range<usize>) {
471        if matches!(self.label, Label::Pending) {
472            self.label = match tag {
473                // A loose definition wraps its content in a paragraph.
474                Tag::Paragraph if depth == CONTENT_DEPTH => Label::Pending,
475                Tag::Emphasis | Tag::Strong => Label::Capturing {
476                    depth,
477                    text: String::new(),
478                },
479                _ => Label::Decided(false),
480            };
481        }
482        match tag {
483            Tag::List(_) if depth == CONTENT_DEPTH && self.list_depth.is_none() => {
484                self.list_depth = Some(depth);
485            }
486            Tag::Item if !self.list_closed && self.list_depth.is_some_and(|d| d + 1 == depth) => {
487                self.last_item = Some(range.clone());
488            }
489            _ => {}
490        }
491    }
492
493    /// An `End` that closes a tag opened at `depth`.
494    fn end(&mut self, tag: &TagEnd, depth: usize) {
495        if let Label::Capturing {
496            depth: opened,
497            text,
498        } = &self.label
499            && *opened == depth
500        {
501            self.label = Label::Decided(is_history_label(text));
502        }
503        if matches!(tag, TagEnd::List(_)) && self.list_depth == Some(depth) {
504            self.list_closed = true;
505        }
506    }
507
508    /// Any non-tag event inside the definition.
509    fn inline(&mut self, event: &Event<'_>) {
510        match &mut self.label {
511            Label::Capturing { text, .. } => {
512                if let Event::Text(t) | Event::Code(t) = event {
513                    text.push_str(t);
514                }
515            }
516            // Leading text before any emphasis: an ordinary answer.
517            Label::Pending => self.label = Label::Decided(false),
518            Label::Decided(_) => {}
519        }
520    }
521
522    fn finish(self) -> Definition {
523        Definition {
524            range: self.range,
525            history: matches!(self.label, Label::Decided(true)),
526            last_item: self.last_item,
527        }
528    }
529}
530
531/// Every top-level definition-list term in `source`, in document order.
532fn scan_terms(source: &str) -> Vec<Term> {
533    let body = strip_bom(source);
534    let shift = source.len() - body.len();
535    let lines = LineIndex::build(source);
536
537    let mut terms = Vec::new();
538    let mut depth = 0usize;
539    let mut in_top_list = false;
540    let mut term: Option<Term> = None;
541    let mut definition: Option<DefinitionScan> = None;
542
543    for (event, range) in Parser::new_ext(body, markdown_options()).into_offset_iter() {
544        let range = range.start + shift..range.end + shift;
545        match event {
546            Event::Start(tag) => {
547                match (&tag, depth) {
548                    (Tag::DefinitionList, 0) => in_top_list = true,
549                    (Tag::DefinitionListTitle, 1) if in_top_list => {
550                        let next = Term {
551                            line: lines.line_of(range.start),
552                            definitions: Vec::new(),
553                        };
554                        terms.extend(term.replace(next));
555                    }
556                    (Tag::DefinitionListDefinition, 1) if in_top_list => {
557                        definition = Some(DefinitionScan::new(range));
558                    }
559                    _ => {
560                        if let Some(scan) = definition.as_mut() {
561                            scan.start(&tag, depth, &range);
562                        }
563                    }
564                }
565                depth += 1;
566            }
567            Event::End(tag) => {
568                depth = depth.saturating_sub(1);
569                match (&tag, depth) {
570                    (TagEnd::DefinitionListDefinition, 1) if in_top_list => {
571                        if let (Some(scan), Some(open)) = (definition.take(), term.as_mut()) {
572                            open.definitions.push(scan.finish());
573                        }
574                    }
575                    (TagEnd::DefinitionList, 0) if in_top_list => {
576                        in_top_list = false;
577                        terms.extend(term.take());
578                    }
579                    _ => {
580                        if let Some(scan) = definition.as_mut() {
581                            scan.end(&tag, depth);
582                        }
583                    }
584                }
585            }
586            other => {
587                if let Some(scan) = definition.as_mut() {
588                    scan.inline(&other);
589                }
590            }
591        }
592    }
593    terms.extend(term);
594    terms
595}
596
597// ============================================================================
598// Source surgery
599// ============================================================================
600
601/// Byte offset of the start of the line containing `offset`.
602fn line_start(source: &str, offset: usize) -> usize {
603    source[..offset]
604        .rfind('\n')
605        .map_or(0, |newline| newline + 1)
606}
607
608/// The text of the line containing `offset`, without its terminator.
609fn line_containing(source: &str, offset: usize) -> &str {
610    let start = line_start(source, offset);
611    let end = source[start..]
612        .find('\n')
613        .map_or(source.len(), |newline| start + newline);
614    source[start..end].trim_end_matches('\r')
615}
616
617/// The last line holding anything but whitespace within `range`. A loose
618/// block's range can run on over trailing blank lines, and inserting after
619/// those would detach the new line from the block it belongs to.
620fn last_line_of(source: &str, lines: &LineIndex, range: &Range<usize>) -> u32 {
621    let text = &source[range.clone()];
622    let content = text.trim_end();
623    if content.is_empty() {
624        lines.line_of(range.start)
625    } else {
626        lines.line_of(range.start + content.len() - 1)
627    }
628}
629
630/// The `: ` prefix (leading blanks, colon, following blanks) of the definition
631/// starting at `offset`, for a new definition placed after it.
632///
633/// The leading blanks are copied, and so is a gap of one to four spaces after
634/// the colon. A wider gap, or one holding a tab, is *not*: five or more columns
635/// after the marker make the definition's content an indented code block, so
636/// a label written with that prefix would be code, never recognised as the
637/// history, and every review would add another one. Those get `": "`.
638fn definition_prefix(source: &str, offset: usize) -> String {
639    /// The widest gap after `:` that still starts ordinary content.
640    const MAX_GAP: usize = 4;
641    let line = line_containing(source, offset);
642    let lead = line.len() - line.trim_start_matches([' ', '\t']).len();
643    let gap = line[lead..]
644        .strip_prefix(':')
645        .map_or(0, |rest| rest.len() - rest.trim_start_matches(' ').len());
646    let gap = if (1..=MAX_GAP).contains(&gap) { gap } else { 1 };
647    let lead = if line[lead..].starts_with(':') {
648        &line[..lead]
649    } else {
650        ""
651    };
652    format!("{lead}:{}", " ".repeat(gap))
653}
654
655/// Indentation that puts a line inside a definition whose first line starts
656/// with `prefix` — the prefix with its colon blanked out.
657fn content_indent(prefix: &str) -> String {
658    prefix.replace(':', " ")
659}
660
661/// A list item for `entry` shaped like the item starting at `item_start`: same
662/// indentation, marker and gap.
663fn bullet_after(source: &str, item_start: usize, entry: &str) -> String {
664    let start = line_start(source, item_start);
665    // Only whitespace is expected in front of a top-level definition's list
666    // item; blank anything else so the column survives regardless.
667    let indent: String = source[start..item_start]
668        .chars()
669        .map(|c| if c == '\t' { '\t' } else { ' ' })
670        .collect();
671    let rest = line_containing(source, item_start);
672    let rest = &rest[item_start - start..];
673    let marker_len = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
674    let marker = &rest[..marker_len];
675    let after = &rest[marker_len..];
676    let gap = &after[..after.len() - after.trim_start_matches([' ', '\t']).len()];
677    let (marker, gap) = if marker.is_empty() {
678        ("*", " ")
679    } else if gap.is_empty() {
680        (marker, " ")
681    } else {
682        (marker, gap)
683    };
684    format!("{indent}{marker}{gap}{entry}")
685}
686
687/// `source` with `inserted` placed after its 1-based `after_line`.
688///
689/// Each inserted line takes the terminator of the line it follows. When that
690/// line is the last one and has none, the file gains a terminator *before* the
691/// insert (`fallback`), and the insert itself ends unterminated, so the file
692/// still does not end in a newline.
693fn insert_lines_after(
694    source: &str,
695    after_line: u32,
696    inserted: &[String],
697    fallback: &str,
698) -> String {
699    let added: usize = inserted.iter().map(|line| line.len() + 2).sum();
700    let mut out = String::with_capacity(source.len() + added);
701    let Some(span) = line_span(source, after_line) else {
702        // Unreachable for a line found by scanning `source`; append instead of
703        // losing the entry.
704        out.push_str(source);
705        for line in inserted {
706            out.push_str(fallback);
707            out.push_str(line);
708        }
709        return out;
710    };
711    let (_, terminator) = split_line_terminator(&source[span.clone()]);
712    if terminator.is_empty() {
713        out.push_str(source);
714        for line in inserted {
715            out.push_str(fallback);
716            out.push_str(line);
717        }
718    } else {
719        out.push_str(&source[..span.end]);
720        for line in inserted {
721            out.push_str(line);
722            out.push_str(terminator);
723        }
724        out.push_str(&source[span.end..]);
725    }
726    out
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use chrono::NaiveDate;
733    use proptest::prelude::*;
734
735    fn at(h: u32, m: u32) -> NaiveDateTime {
736        NaiveDate::from_ymd_opt(2026, 10, 6)
737            .and_then(|d| d.and_hms_opt(h, m, 0))
738            .expect("valid test time")
739    }
740
741    /// Appends a `Good` at 13:45 to the term on `line`, reading `expected`
742    /// straight from the source.
743    fn append(source: &str, line: u32) -> Result<ReviewPatch, FlashcardPatchError> {
744        let expected = source
745            .split('\n')
746            .nth(line as usize - 1)
747            .unwrap_or_default()
748            .trim_end_matches('\r');
749        append_review(source, line, expected, Rating::Good, at(13, 45))
750    }
751
752    fn appended(source: &str, line: u32) -> String {
753        append(source, line).expect("append succeeds").source
754    }
755
756    // ---- format -------------------------------------------------------------
757
758    #[test]
759    fn entry_format_is_minutes_and_capitalised_rating() {
760        assert_eq!(
761            format_entry(at(9, 5), Rating::Again),
762            "2026-10-06 09:05 - Again"
763        );
764        assert_eq!(
765            format_entry(at(23, 59), Rating::Easy),
766            "2026-10-06 23:59 - Easy"
767        );
768    }
769
770    // ---- the reviewer's clock ----------------------------------------------
771
772    /// The server's UTC "now" for the window tests.
773    fn utc_now() -> NaiveDateTime {
774        at(12, 0)
775    }
776
777    #[test]
778    fn review_time_accepts_any_real_time_zone() {
779        // UTC-12 (Baker Island) to UTC+14 (Kiribati), and UTC itself.
780        for at in ["2026-10-06 00:00", "2026-10-06 12:00", "2026-10-07 02:00"] {
781            assert_eq!(
782                parse_review_time(at, utc_now()).map(|t| format_entry(t, Rating::Good)),
783                Ok(format!("{at} - Good")),
784                "{at}"
785            );
786        }
787        // The window's edges are inclusive.
788        assert!(parse_review_time("2026-10-05 10:00", utc_now()).is_ok());
789        assert!(parse_review_time("2026-10-07 14:00", utc_now()).is_ok());
790    }
791
792    #[test]
793    fn review_time_refuses_times_no_time_zone_can_explain() {
794        for at in [
795            "2026-10-05 09:59", // 26h01m behind
796            "2026-10-07 14:01", // 26h01m ahead
797            "1970-01-01 00:00",
798            "9999-12-31 23:59",
799        ] {
800            assert_eq!(
801                parse_review_time(at, utc_now()),
802                Err(ReviewTimeError::OutOfRange),
803                "{at}"
804            );
805        }
806    }
807
808    #[test]
809    fn review_time_is_strictly_the_entry_format() {
810        for at in [
811            "",
812            "now",
813            "2026-10-06",
814            "2026-10-06 12:00:00", // seconds are not stored
815            "2026-10-06T12:00",
816            "2026-10-06 12:00Z",
817            "2026-10-06 12:00 ",
818            " 2026-10-06 12:00",
819            "2026-10-6 12:00",
820            "2026-10-06 2:00",
821            "+2026-10-06 12:00",
822            "2026-10-06 12:00", // a full-width digit
823            "2026-10-06 12:00 - Good",
824        ] {
825            assert_eq!(
826                parse_review_time(at, utc_now()),
827                Err(ReviewTimeError::Malformed),
828                "{at:?}"
829            );
830        }
831    }
832
833    #[test]
834    fn review_time_refuses_impossible_calendar_values() {
835        for at in [
836            "2026-13-01 12:00",
837            "2026-00-10 12:00",
838            "2026-02-29 12:00", // not a leap year
839            "2026-10-32 12:00",
840            "2026-10-06 24:00",
841            "2026-10-06 12:60",
842            // A leap second has no minute-precision spelling: 23:59:60 is
843            // malformed (no seconds) and 23:60 is not a minute.
844            "2016-12-31 23:60",
845            "2016-12-31 23:59:60",
846        ] {
847            assert_eq!(
848                parse_review_time(at, utc_now()),
849                Err(ReviewTimeError::Malformed),
850                "{at}"
851            );
852        }
853    }
854
855    proptest! {
856        /// Never panics, and anything it accepts round-trips through the entry
857        /// format unchanged — which is what the server writes.
858        #[test]
859        fn review_time_never_panics_and_round_trips(at in "\\PC{0,24}") {
860            if let Ok(time) = parse_review_time(&at, utc_now()) {
861                prop_assert_eq!(time.format(ENTRY_TIME_FORMAT).to_string(), at);
862            }
863        }
864    }
865
866    #[test]
867    fn rating_deserialises_lowercase_only() {
868        let good: Rating = serde_json::from_str("\"good\"").unwrap();
869        assert_eq!(good, Rating::Good);
870        assert!(serde_json::from_str::<Rating>("\"Good\"").is_err());
871        assert!(serde_json::from_str::<Rating>("\"fail\"").is_err());
872    }
873
874    #[test]
875    fn history_label_matching() {
876        assert!(is_history_label("Review History"));
877        assert!(is_history_label("  review history "));
878        assert!(is_history_label("REVIEW HISTORY"));
879        assert!(!is_history_label("Review"));
880        assert!(!is_history_label("Review History of Rome"));
881    }
882
883    // ---- creating a history -------------------------------------------------
884
885    #[test]
886    fn first_review_creates_the_history_definition() {
887        let source = "Q?\n: A.\n\nNext?\n: B.\n";
888        let patch = append(source, 1).unwrap();
889        assert_eq!(
890            patch.source,
891            "Q?\n: A.\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n\nNext?\n: B.\n"
892        );
893        assert_eq!(patch.entry, "2026-10-06 13:45 - Good");
894        assert_eq!(patch.inserted_at, 3);
895        assert_eq!(
896            patch.inserted,
897            vec![": ___Review History___", "  * 2026-10-06 13:45 - Good"]
898        );
899        assert_eq!(patch.entry_line(), 4);
900    }
901
902    #[test]
903    fn history_goes_after_the_last_of_several_answers() {
904        let source = "Q?\n: one\n: two\n";
905        assert_eq!(
906            appended(source, 1),
907            "Q?\n: one\n: two\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n"
908        );
909    }
910
911    #[test]
912    fn history_copies_a_wider_definition_prefix() {
913        let source = "Q?\n:   A wide answer\n";
914        assert_eq!(
915            appended(source, 1),
916            "Q?\n:   A wide answer\n:   ___Review History___\n    * 2026-10-06 13:45 - Good\n"
917        );
918    }
919
920    #[test]
921    fn loose_definitions_get_the_history_after_the_last_content_line() {
922        let source = "Q?\n\n: A.\n\n: B.\n\nNext?\n\n: C.\n";
923        assert_eq!(
924            appended(source, 1),
925            "Q?\n\n: A.\n\n: B.\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n\nNext?\n\n: C.\n"
926        );
927    }
928
929    #[test]
930    fn multi_line_answer_with_nested_list_and_code() {
931        let source = concat!(
932            "Q?\n",
933            ": Steps:\n",
934            "  * first\n",
935            "  * second\n",
936            "\n",
937            "  ```\n",
938            "  : not a definition\n",
939            "  ```\n",
940            "\n",
941            "Next?\n",
942            ": B.\n",
943        );
944        let patched = appended(source, 1);
945        assert_eq!(
946            patched,
947            concat!(
948                "Q?\n",
949                ": Steps:\n",
950                "  * first\n",
951                "  * second\n",
952                "\n",
953                "  ```\n",
954                "  : not a definition\n",
955                "  ```\n",
956                ": ___Review History___\n",
957                "  * 2026-10-06 13:45 - Good\n",
958                "\n",
959                "Next?\n",
960                ": B.\n",
961            )
962        );
963        // And the nested list did not get mistaken for the history.
964        let terms = scan_terms(&patched);
965        assert_eq!(terms[0].definitions.len(), 2);
966        assert!(!terms[0].definitions[0].history);
967        assert!(terms[0].definitions[1].history);
968    }
969
970    // ---- appending to a history ---------------------------------------------
971
972    #[test]
973    fn appends_after_the_last_history_bullet() {
974        let source = concat!(
975            "Q?\n",
976            ": A.\n",
977            ": ___Review History___\n",
978            "  * 2026-10-01 08:00 - Again\n",
979            "  * 2026-10-02 09:00 - Good\n",
980            "\n",
981            "Next?\n",
982            ": B.\n",
983        );
984        let patch = append(source, 1).unwrap();
985        assert_eq!(patch.inserted, vec!["  * 2026-10-06 13:45 - Good"]);
986        assert_eq!(patch.inserted_at, 6);
987        assert_eq!(patch.entry_line(), 6);
988        assert_eq!(
989            patch.source,
990            concat!(
991                "Q?\n",
992                ": A.\n",
993                ": ___Review History___\n",
994                "  * 2026-10-01 08:00 - Again\n",
995                "  * 2026-10-02 09:00 - Good\n",
996                "  * 2026-10-06 13:45 - Good\n",
997                "\n",
998                "Next?\n",
999                ": B.\n",
1000            )
1001        );
1002    }
1003
1004    #[test]
1005    fn keeps_dash_markers_and_wide_gaps() {
1006        let source = "Q?\n: A.\n: **Review History**\n  -   2026-10-01 08:00 - Hard\n";
1007        assert_eq!(
1008            appended(source, 1),
1009            "Q?\n: A.\n: **Review History**\n  -   2026-10-01 08:00 - Hard\n  -   2026-10-06 13:45 - Good\n"
1010        );
1011    }
1012
1013    #[test]
1014    fn recognises_single_emphasis_and_any_case() {
1015        let source = "Q?\n: A.\n: *review history*\n  * 2026-10-01 08:00 - Fail\n";
1016        let patch = append(source, 1).unwrap();
1017        assert_eq!(patch.inserted, vec!["  * 2026-10-06 13:45 - Good"]);
1018    }
1019
1020    #[test]
1021    fn plain_text_review_history_is_an_answer_not_a_history() {
1022        // Without emphasis it is just an answer that happens to say that.
1023        let source = "Q?\n: Review History\n";
1024        let patch = append(source, 1).unwrap();
1025        assert_eq!(patch.inserted.len(), 2, "a real history is created");
1026    }
1027
1028    #[test]
1029    fn label_only_history_gets_its_first_bullet() {
1030        let source = "Q?\n: A.\n: ___Review History___\n\nNext?\n: B.\n";
1031        assert_eq!(
1032            appended(source, 1),
1033            "Q?\n: A.\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n\nNext?\n: B.\n"
1034        );
1035    }
1036
1037    #[test]
1038    fn loose_history_list_appends_after_last_item() {
1039        let source = concat!(
1040            "Q?\n\n",
1041            ": A.\n\n",
1042            ": ___Review History___\n\n",
1043            "  * 2026-10-01 08:00 - Good\n\n",
1044            "  * 2026-10-02 08:00 - Easy\n\n",
1045            "Next?\n\n",
1046            ": B.\n",
1047        );
1048        let patched = appended(source, 1);
1049        assert!(
1050            patched.contains("  * 2026-10-02 08:00 - Easy\n  * 2026-10-06 13:45 - Good\n\nNext?"),
1051            "{patched}"
1052        );
1053    }
1054
1055    #[test]
1056    fn unparseable_history_lines_are_left_alone() {
1057        let source = "Q?\n: A.\n: ___Review History___\n  * yesterday, sort of\n";
1058        assert_eq!(
1059            appended(source, 1),
1060            "Q?\n: A.\n: ___Review History___\n  * yesterday, sort of\n  * 2026-10-06 13:45 - Good\n"
1061        );
1062    }
1063
1064    // ---- addressing -------------------------------------------------------
1065
1066    #[test]
1067    fn second_term_in_the_same_list_and_terms_in_other_lists() {
1068        let source = concat!(
1069            "# Deck\n",       // 1
1070            "\n",             // 2
1071            "One?\n",         // 3
1072            ": 1\n",          // 4
1073            "\n",             // 5
1074            "Two?\n",         // 6
1075            ": 2\n",          // 7
1076            "\n",             // 8
1077            "A paragraph.\n", // 9
1078            "\n",             // 10
1079            "Three?\n",       // 11
1080            ": 3\n",          // 12
1081        );
1082        assert!(appended(source, 6).contains("Two?\n: 2\n: ___Review History___\n  * "));
1083        assert!(
1084            appended(source, 11)
1085                .ends_with("Three?\n: 3\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n")
1086        );
1087        let one = appended(source, 3);
1088        assert!(
1089            one.starts_with("# Deck\n\nOne?\n: 1\n: ___Review History___\n"),
1090            "{one}"
1091        );
1092    }
1093
1094    #[test]
1095    fn frontmatter_counts_toward_line_numbers() {
1096        let source = "---\ntype: flashcard\n---\n\nQ?\n: A.\n";
1097        assert!(
1098            appended(source, 5)
1099                .ends_with(": A.\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n")
1100        );
1101        // The frontmatter's `type: flashcard` is not a term.
1102        assert_eq!(
1103            append(source, 2),
1104            Err(FlashcardPatchError::NotATerm { line: 2 })
1105        );
1106    }
1107
1108    #[test]
1109    fn bom_does_not_shift_anything() {
1110        let source = "\u{feff}---\ntype: flashcard\n---\nQ?\n: A.\n";
1111        let patched = appended(source, 4);
1112        assert!(patched.starts_with('\u{feff}'));
1113        assert!(patched.ends_with(": A.\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n"));
1114    }
1115
1116    #[test]
1117    fn colon_lines_in_code_fences_are_not_terms() {
1118        let source = "```\nFake?\n: not a definition\n```\n\nReal?\n: yes\n";
1119        assert_eq!(
1120            append(source, 2),
1121            Err(FlashcardPatchError::NotATerm { line: 2 })
1122        );
1123        assert!(
1124            appended(source, 6)
1125                .ends_with(": yes\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n")
1126        );
1127    }
1128
1129    /// A term inside a ```` ```chat ```` body renders as a real `<dt>` with a
1130    /// `data-mbr-line`, but in the source it is code-fence text. The writer must
1131    /// refuse it, which is why the deck treats `.mbr-chat` as nesting.
1132    #[test]
1133    fn terms_inside_chat_blocks_are_not_terms() {
1134        let source = "```chat\n{{Alice|Chat term\n: chat answer\n|}}\n```\n\nReal?\n: yes\n";
1135        assert_eq!(
1136            append(source, 2),
1137            Err(FlashcardPatchError::NotATerm { line: 2 })
1138        );
1139        assert!(
1140            appended(source, 7)
1141                .ends_with(": yes\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n")
1142        );
1143    }
1144
1145    #[test]
1146    fn definition_lines_and_prose_are_not_terms() {
1147        let source = "Prose.\n\nQ?\n: A.\n";
1148        assert_eq!(
1149            append(source, 1),
1150            Err(FlashcardPatchError::NotATerm { line: 1 })
1151        );
1152        assert_eq!(
1153            append(source, 4),
1154            Err(FlashcardPatchError::NotATerm { line: 4 })
1155        );
1156    }
1157
1158    #[test]
1159    fn nested_definition_lists_are_refused() {
1160        let source = "> Q?\n> : A.\n\n- Q2?\n  : A2.\n";
1161        assert_eq!(
1162            append(source, 1),
1163            Err(FlashcardPatchError::NotATerm { line: 1 })
1164        );
1165        assert_eq!(
1166            append(source, 4),
1167            Err(FlashcardPatchError::NotATerm { line: 4 })
1168        );
1169    }
1170
1171    #[test]
1172    fn mismatch_and_out_of_range() {
1173        let source = "Q?\n: A.\n";
1174        assert_eq!(
1175            append_review(source, 1, "Q? (edited)", Rating::Good, at(1, 2)),
1176            Err(FlashcardPatchError::Mismatch { line: 1 })
1177        );
1178        assert_eq!(
1179            append_review(source, 9, "Q?", Rating::Good, at(1, 2)),
1180            Err(FlashcardPatchError::LineOutOfRange { line: 9 })
1181        );
1182        assert_eq!(
1183            append_review(source, 0, "Q?", Rating::Good, at(1, 2)),
1184            Err(FlashcardPatchError::LineOutOfRange { line: 0 })
1185        );
1186    }
1187
1188    #[test]
1189    fn expected_is_compared_modulo_terminator() {
1190        let source = "Q?\r\n: A.\r\n";
1191        assert!(append_review(source, 1, "Q?\r\n", Rating::Hard, at(1, 2)).is_ok());
1192        assert!(append_review(source, 1, "Q?", Rating::Hard, at(1, 2)).is_ok());
1193    }
1194
1195    // ---- terminators --------------------------------------------------------
1196
1197    #[test]
1198    fn crlf_is_preserved() {
1199        let source = "Q?\r\n: A.\r\n: ___Review History___\r\n  * 2026-10-01 08:00 - Good\r\n";
1200        assert_eq!(
1201            appended(source, 1),
1202            "Q?\r\n: A.\r\n: ___Review History___\r\n  * 2026-10-01 08:00 - Good\r\n  * 2026-10-06 13:45 - Good\r\n"
1203        );
1204        let fresh = "Q?\r\n: A.\r\n";
1205        assert_eq!(
1206            appended(fresh, 1),
1207            "Q?\r\n: A.\r\n: ___Review History___\r\n  * 2026-10-06 13:45 - Good\r\n"
1208        );
1209    }
1210
1211    #[test]
1212    fn missing_trailing_newline_stays_missing() {
1213        assert_eq!(
1214            appended("Q?\n: A.", 1),
1215            "Q?\n: A.\n: ___Review History___\n  * 2026-10-06 13:45 - Good"
1216        );
1217        assert_eq!(
1218            appended("Q?\r\n: A.", 1),
1219            "Q?\r\n: A.\r\n: ___Review History___\r\n  * 2026-10-06 13:45 - Good"
1220        );
1221        assert_eq!(
1222            appended(
1223                "Q?\n: A.\n: ___Review History___\n  * 2026-10-01 08:00 - Good",
1224                1
1225            ),
1226            "Q?\n: A.\n: ___Review History___\n  * 2026-10-01 08:00 - Good\n  * 2026-10-06 13:45 - Good"
1227        );
1228    }
1229
1230    #[test]
1231    fn repeated_reviews_accumulate() {
1232        let mut source = "Q?\n: A.\n\nNext?\n: B.\n".to_string();
1233        for (i, rating) in [Rating::Again, Rating::Hard, Rating::Good, Rating::Easy]
1234            .into_iter()
1235            .enumerate()
1236        {
1237            source = append_review(&source, 1, "Q?", rating, at(10, i as u32))
1238                .unwrap()
1239                .source;
1240        }
1241        assert_eq!(
1242            source,
1243            concat!(
1244                "Q?\n",
1245                ": A.\n",
1246                ": ___Review History___\n",
1247                "  * 2026-10-06 10:00 - Again\n",
1248                "  * 2026-10-06 10:01 - Hard\n",
1249                "  * 2026-10-06 10:02 - Good\n",
1250                "  * 2026-10-06 10:03 - Easy\n",
1251                "\n",
1252                "Next?\n",
1253                ": B.\n",
1254            )
1255        );
1256    }
1257
1258    // ---- terms with no blank line before them --------------------------------
1259
1260    /// The `<dt>` texts of `source` rendered exactly as a page is — the same
1261    /// parser and [`markdown_options`] — so these tests do not rely on
1262    /// [`scan_terms`] agreeing with itself.
1263    fn rendered_terms(source: &str) -> Vec<String> {
1264        let mut html = String::new();
1265        pulldown_cmark::html::push_html(&mut html, Parser::new_ext(source, markdown_options()));
1266        html.split("<dt>")
1267            .skip(1)
1268            .map(|rest| rest[..rest.find("</dt>").expect("closed dt")].to_string())
1269            .collect()
1270    }
1271
1272    /// Rates the term on `line` and checks the write kept every card — by the
1273    /// renderer's parse and by [`outline`] — while adding one history entry.
1274    fn assert_rating_keeps_cards(source: &str, line: u32) -> String {
1275        let terms = rendered_terms(source);
1276        let before = outline(source);
1277        let patched = appended(source, line);
1278        assert_eq!(rendered_terms(&patched), terms, "{patched}");
1279        let after = outline(&patched);
1280        assert_eq!(after.len(), before.len(), "{patched}");
1281        for (i, (b, a)) in before.iter().zip(&after).enumerate() {
1282            assert_eq!((&b.0, &b.1), (&a.0, &a.1), "card {i}: {patched}");
1283            let target = b.0 == line_containing(source, line_span(source, line).unwrap().start);
1284            let grown = if target {
1285                b.2.unwrap_or(0) + 1
1286            } else {
1287                b.2.unwrap_or(0)
1288            };
1289            assert_eq!(a.2.unwrap_or(0), grown, "card {i}: {patched}");
1290        }
1291        patched
1292    }
1293
1294    /// `Q2` straight after an answer is not a second term at all: it is a lazy
1295    /// continuation of `A1`'s paragraph, before any write, so the deck never
1296    /// offered it as a card. A pulldown-cmark definition list needs a blank
1297    /// line (or a non-paragraph block) before the next term.
1298    #[test]
1299    fn a_term_glued_to_an_answer_is_a_lazy_continuation() {
1300        let source = "Q1\n: A1\nQ2\n: A2\n";
1301        assert_eq!(rendered_terms(source), ["Q1"]);
1302        assert_eq!(
1303            append(source, 3),
1304            Err(FlashcardPatchError::NotATerm { line: 3 })
1305        );
1306        let patched = assert_rating_keeps_cards(source, 1);
1307        assert_eq!(
1308            patched,
1309            "Q1\n: A1\nQ2\n: A2\n: ___Review History___\n  * 2026-10-06 13:45 - Good\n"
1310        );
1311    }
1312
1313    /// The same inside an existing history: `Q2` continues the last bullet.
1314    #[test]
1315    fn a_term_glued_to_a_history_bullet_is_a_lazy_continuation() {
1316        let source = "Q1\n: A1\n: ___Review History___\n  * 2026-10-01 08:00 - Good\nQ2\n: A2\n";
1317        assert_eq!(rendered_terms(source), ["Q1"]);
1318        assert_rating_keeps_cards(source, 1);
1319    }
1320
1321    #[test]
1322    fn a_term_glued_to_a_loose_answer_is_a_lazy_continuation() {
1323        let source = "Q1\n\n: A1\nQ2\n\n: A2\n";
1324        assert_eq!(rendered_terms(source), ["Q1"]);
1325        assert_rating_keeps_cards(source, 1);
1326    }
1327
1328    /// A definition ending in a fenced block leaves no paragraph to continue,
1329    /// so here the glued `Q2` *is* a term — and a history written straight
1330    /// above it would hand it a bullet to lazily continue, silently merging
1331    /// the card into the previous one.
1332    #[test]
1333    fn a_term_glued_to_a_closed_fence_stays_a_term() {
1334        let source = "Q1\n: A1\n\n  ```\n  code\n  ```\nQ2\n: A2\n";
1335        assert_eq!(rendered_terms(source), ["Q1", "Q2"]);
1336        let patched = assert_rating_keeps_cards(source, 1);
1337        assert_eq!(
1338            patched,
1339            concat!(
1340                "Q1\n: A1\n\n  ```\n  code\n  ```\n",
1341                ": ___Review History___\n",
1342                "  * 2026-10-06 13:45 - Good\n",
1343                "\n",
1344                "Q2\n: A2\n",
1345            )
1346        );
1347        // The separating blank line is reported, so a client shifting its line
1348        // numbers by `inserted.len()` stays right, but the entry's own line
1349        // skips it.
1350        let patch = append(source, 1).unwrap();
1351        assert_eq!(patch.inserted.last().map(String::as_str), Some(""));
1352        assert_eq!((patch.inserted_at, patch.entry_line()), (7, 8));
1353        // `Q2`'s card renders exactly as it did before the write.
1354        let q2_card = |source: &str| {
1355            let mut html = String::new();
1356            pulldown_cmark::html::push_html(&mut html, Parser::new_ext(source, markdown_options()));
1357            html[html.find("<dt>Q2</dt>").expect("Q2 is a term")..].to_string()
1358        };
1359        assert_eq!(q2_card(&patched), q2_card(source));
1360        // …and the next rating appends to the history, above the blank line.
1361        let again = assert_rating_keeps_cards(&patched, 1);
1362        assert!(
1363            again.contains("13:45 - Good\n  * 2026-10-06 13:45 - Good\n\nQ2\n"),
1364            "{again}"
1365        );
1366    }
1367
1368    /// A term may be indented up to three spaces. One space after a closed
1369    /// fence keeps `Q2` outside the definition (whose content column is two),
1370    /// so it is a card — and must stay one.
1371    #[test]
1372    fn an_indented_term_glued_to_a_closed_fence_stays_a_term() {
1373        let source = "Q1\n: A1\n\n  ```\n  code\n  ```\n Q2\n: A2\n";
1374        assert_eq!(rendered_terms(source), ["Q1", "Q2"]);
1375        let patched = assert_rating_keeps_cards(source, 1);
1376        assert_eq!(
1377            patched,
1378            concat!(
1379                "Q1\n: A1\n\n  ```\n  code\n  ```\n",
1380                ": ___Review History___\n",
1381                "  * 2026-10-06 13:45 - Good\n",
1382                "\n",
1383                " Q2\n: A2\n",
1384            )
1385        );
1386        // Rating the indented card itself works too.
1387        assert_rating_keeps_cards(source, 7);
1388        // Two or three spaces put the line inside `Q1`'s definition instead
1389        // (a paragraph after the fence): not a card, before or after.
1390        for indent in ["  ", "   "] {
1391            let inside = source.replace("\n Q2", &format!("\n{indent}Q2"));
1392            assert_eq!(rendered_terms(&inside), ["Q1"], "{inside:?}");
1393            assert_rating_keeps_cards(&inside, 1);
1394        }
1395    }
1396
1397    /// The same trap one level down: the history's last bullet ends in a
1398    /// fence, and the next card's term is glued to it.
1399    #[test]
1400    fn a_term_glued_to_a_history_bullet_ending_in_a_fence_stays_a_term() {
1401        for indent in ["", " "] {
1402            let source = format!(
1403                concat!(
1404                    "Q1\n",
1405                    ": A1\n",
1406                    ": ___Review History___\n",
1407                    "  * 2026-10-01 08:00 - Good\n",
1408                    "\n",
1409                    "    ```\n",
1410                    "    x\n",
1411                    "    ```\n",
1412                    "{}Q2\n",
1413                    ": A2\n",
1414                ),
1415                indent
1416            );
1417            assert_eq!(rendered_terms(&source), ["Q1", "Q2"], "{source:?}");
1418            let patch = append(&source, 1).unwrap();
1419            assert_eq!(
1420                patch.inserted,
1421                vec!["  * 2026-10-06 13:45 - Good", ""],
1422                "{source:?}"
1423            );
1424            assert_eq!((patch.inserted_at, patch.entry_line()), (9, 9));
1425            let again = assert_rating_keeps_cards(&assert_rating_keeps_cards(&source, 1), 1);
1426            assert_eq!(rendered_terms(&again), ["Q1", "Q2"]);
1427        }
1428    }
1429
1430    /// The re-parse refuses a patch that would swallow the next card, which is
1431    /// what the glued-line rule exists to avoid.
1432    #[test]
1433    fn verification_rejects_a_patch_that_swallows_a_card() {
1434        let source = "Q1\n: A1\n\n  ```\n  code\n  ```\n Q2\n: A2\n";
1435        let inserted = vec![
1436            ": ___Review History___".to_string(),
1437            "  * 2026-10-06 13:45 - Good".to_string(),
1438        ];
1439        let bad = ReviewPatch {
1440            source: insert_lines_after(source, 6, &inserted, "\n"),
1441            entry: "2026-10-06 13:45 - Good".to_string(),
1442            inserted_at: 7,
1443            inserted,
1444        };
1445        assert_eq!(rendered_terms(&bad.source), ["Q1"], "the patch is bad");
1446        let term_lines: Vec<u32> = scan_terms(source).iter().map(|t| t.line).collect();
1447        assert_eq!(term_lines, [1, 7]);
1448        assert!(!keeps_every_card(&bad, &term_lines, 1));
1449        assert!(keeps_every_card(
1450            &append(source, 1).unwrap(),
1451            &term_lines,
1452            1
1453        ));
1454    }
1455
1456    /// Five or more columns after `:` make the answer an indented code block.
1457    /// Copying that prefix onto the history label would make it code too:
1458    /// never recognised, so every review would add another history.
1459    #[test]
1460    fn a_wide_or_tabbed_answer_gap_is_not_copied_onto_the_history() {
1461        for source in [
1462            "Q?\n:     A\n",
1463            "Q?\n:\t\tA\n",
1464            "Q?\n: \tA\n",
1465            "Q?\n:      A\n",
1466        ] {
1467            let mut patched = source.to_string();
1468            for minute in 0..3 {
1469                patched = append_review(&patched, 1, "Q?", Rating::Good, at(10, minute))
1470                    .unwrap()
1471                    .source;
1472            }
1473            assert_eq!(
1474                patched,
1475                format!(
1476                    "{source}: ___Review History___\n  * 2026-10-06 10:00 - Good\n  * 2026-10-06 10:01 - Good\n  * 2026-10-06 10:02 - Good\n"
1477                ),
1478                "{source:?}"
1479            );
1480            let terms = scan_terms(&patched);
1481            assert_eq!(terms.len(), 1);
1482            assert_eq!(
1483                terms[0].definitions.iter().filter(|d| d.history).count(),
1484                1,
1485                "{patched:?}"
1486            );
1487        }
1488        // Up to four spaces is ordinary content, and is copied with its indent.
1489        assert_eq!(
1490            appended("Q?\n:    A\n", 1),
1491            "Q?\n:    A\n:    ___Review History___\n     * 2026-10-06 13:45 - Good\n"
1492        );
1493        assert_eq!(
1494            appended("Q?\n : A\n", 1),
1495            "Q?\n : A\n : ___Review History___\n   * 2026-10-06 13:45 - Good\n"
1496        );
1497    }
1498
1499    // ---- a real deck: multi-line terms, quoted and bulleted answers ---------
1500
1501    /// One card exactly as a user's deck writes it: a bold question paragraph,
1502    /// a blank line, the four choices as the (multi-line) term, then answers
1503    /// including a blockquote and a `Memory aids:` answer with an indented
1504    /// `  * ` bullet. `{n}` names the card; `{history}` is the tail after
1505    /// `: Memory aids:`'s bullet (empty for a card never reviewed).
1506    fn ham_card(n: u32, history: &str) -> String {
1507        format!(
1508            concat!(
1509                "**T1-A{n:02}: Who selects a Frequency Coordinator?**\n",
1510                "\n",
1511                "A. The FCC Office of Spectrum Management and Coordination Policy\n",
1512                "B. The local chapter of the Office of National Council of Independent Frequency Coordinators\n",
1513                "C. Amateur operators in a local or regional area whose stations are eligible to be repeater or auxiliary stations\n",
1514                "D. The FCC Regional Field Office\n",
1515                ": **C.** Amateur operators in a local or regional area whose stations are eligible to be repeater or auxiliary stations\n",
1516                ": **47 CFR 97.3(a)(22):**\n",
1517                ": > **Frequency coordinator.** An entity, ***recognized in a local or regional area by amateur operators whose stations are eligible to be auxiliary or repeater stations,*** that recommends transmit/receive channels and associated operating and technical parameters for such stations in order to avoid or minimize potential interference.\n",
1518                ": Frequency coordinators are amateur radio operators who are recognized by other local or regional amateurs (particularly those who operate repeaters or auxiliary stations). They recommend which transmit/receive channels and what operating and technical parameters should be used so repeaters don't interfere with one another. This local recognition means the coordinators are chosen by the amateur community in the area rather than by an FCC office.\n",
1519                ": Coordination is important because if an operator ignores the locally established band plan or the coordinator's recommendations, they might transmit on or near a repeater input and cause harmful interference to others.\n",
1520                ": Memory aids:\n",
1521                "  * Remember: \"Frequency coordinator\" = someone recognized by local/regional amateur operators (not an FCC office).\n",
1522                "{history}",
1523            ),
1524            n = n,
1525            history = history,
1526        )
1527    }
1528
1529    /// The term line of a [`ham_card`] — line 3 of the card: the `<dt>` is the
1530    /// `A.`–`D.` paragraph, not the bold question, which is a `<p>` before the
1531    /// `<dl>`.
1532    const HAM_TERM: &str = "A. The FCC Office of Spectrum Management and Coordination Policy";
1533    const HAM_TERM_OFFSET: u32 = 2;
1534    const HAM_REMEMBER: &str = "  * Remember: \"Frequency coordinator\"";
1535    const HAM_HISTORY: &str = ": ___Review History___\n  * 2026-10-07 14:53 - Good\n";
1536
1537    /// `source` rendered by the real page pipeline ([`crate::markdown::render_str`]
1538    /// with `data-mbr-line` on, as the server renders a deck).
1539    fn render_page(source: &str) -> String {
1540        use crate::link_transform::LinkTransformConfig;
1541        use crate::markdown::{RenderOptions, ReviewLines, render_str};
1542        let root = std::path::Path::new("/nonexistent-root");
1543        let opts = RenderOptions {
1544            root_path: root,
1545            oembed_timeout_ms: 0,
1546            link_transform_config: LinkTransformConfig {
1547                markdown_extensions: vec!["md".to_string()],
1548                index_file: "index.md".to_string(),
1549                is_index_file: false,
1550                url_depth: None,
1551                current_page_url: String::new(),
1552                markdown_page_probe: None,
1553            },
1554            oembed_cache: None,
1555            server_mode: true,
1556            transcode_enabled: false,
1557            valid_tag_sources: Default::default(),
1558            review: ReviewLines::Emit,
1559            mark_incomplete: false,
1560            incomplete_markers: &[],
1561            wikilink_index: None,
1562        };
1563        render_str(source, std::path::Path::new("deck.md"), opts)
1564            .expect("renders")
1565            .html
1566    }
1567
1568    /// Every `data-mbr-line` on a rendered `<dt>` — what the deck sends as
1569    /// `line`.
1570    fn page_term_lines(html: &str) -> Vec<u32> {
1571        html.match_indices("<dt data-mbr-line=\"")
1572            .map(|(at, prefix)| {
1573                let rest = &html[at + prefix.len()..];
1574                rest[..rest.find('"').expect("closing quote")]
1575                    .parse()
1576                    .expect("numeric line")
1577            })
1578            .collect()
1579    }
1580
1581    /// Asserts every list item of the page sits inside a `<dd>` of a `<dl>`:
1582    /// the `Memory aids` bullet and each history entry, never a stray `<ul>`
1583    /// after the list. Returns the number of `<dl>`s.
1584    fn assert_bullets_inside_definitions(html: &str, entries: &[&str]) -> usize {
1585        let mut outside = html.to_string();
1586        let mut lists = 0;
1587        while let Some(open) = outside.find("<dl>") {
1588            let close = open + outside[open..].find("</dl>").expect("closed dl") + "</dl>".len();
1589            let list = outside[open..close].to_string();
1590            assert!(list.contains("<li"), "a <dl> without its bullets: {html}");
1591            outside.replace_range(open..close, "");
1592            lists += 1;
1593        }
1594        assert!(!outside.contains("<li"), "a bullet left the <dl>: {html}");
1595        assert!(!outside.contains("<ul"), "a list left the <dl>: {html}");
1596        for entry in entries {
1597            assert!(html.contains(entry), "{entry:?} missing: {html}");
1598        }
1599        // Each card's `Memory aids` answer still owns its bullet.
1600        let answers = html.matches("Memory aids:").count();
1601        assert!(answers > 0, "{html}");
1602        assert_eq!(
1603            html.matches("Memory aids:\n<ul>\n<li").count(),
1604            answers,
1605            "{html}"
1606        );
1607        lists
1608    }
1609
1610    /// Asserts `patch` is `before` with `patch.inserted` spliced in at
1611    /// `patch.inserted_at` and not one other byte changed.
1612    fn assert_pure_insert(before: &str, patch: &ReviewPatch) {
1613        let terminator = if before.contains("\r\n") {
1614            "\r\n"
1615        } else {
1616            "\n"
1617        };
1618        let mut lines: Vec<&str> = before.split(terminator).collect();
1619        let at = patch.inserted_at as usize - 1;
1620        // A file with no trailing newline gains the terminator before the
1621        // insert, not after it.
1622        let unterminated_tail = !before.ends_with('\n') && at == lines.len();
1623        for (i, line) in patch.inserted.iter().enumerate() {
1624            lines.insert(at + i, line);
1625        }
1626        let rebuilt = lines.join(terminator);
1627        assert_eq!(patch.source, rebuilt, "inserted {:?}", patch.inserted);
1628        if unterminated_tail {
1629            assert!(!patch.source.ends_with('\n'));
1630        }
1631    }
1632
1633    fn ham_append(source: &str, line: u32, rating: Rating, minute: u32) -> ReviewPatch {
1634        let patch = append_review(source, line, HAM_TERM, rating, at(13, minute))
1635            .unwrap_or_else(|e| panic!("append at {line}: {e:?}\n{source}"));
1636        assert_pure_insert(source, &patch);
1637        patch
1638    }
1639
1640    /// The reported corruption — a blank line, then the bullets at column 0 —
1641    /// takes them out of the `<dl>`. Pins that the checks above can see it, so
1642    /// their passing means the writer does not produce it.
1643    #[test]
1644    fn ham_deck_reported_corruption_moves_bullets_out_of_the_list() {
1645        let good = ham_card(9, HAM_HISTORY);
1646        let broken = good
1647            .replace("\n  * Remember", "\n\n* Remember")
1648            .replace("\n  * 2026", "\n\n* 2026");
1649        let html = render_page(&broken);
1650        let remember = html.find("Remember: ").expect("rendered");
1651        let dl_end = html.find("</dl>").expect("a <dl>");
1652        assert!(dl_end < remember, "{html}");
1653        assert!(
1654            std::panic::catch_unwind(|| assert_bullets_inside_definitions(&html, &[])).is_err()
1655        );
1656        assert!(
1657            std::panic::catch_unwind(|| {
1658                assert_bullets_inside_definitions(&render_page(&good), &[])
1659            })
1660            .is_ok()
1661        );
1662    }
1663
1664    #[test]
1665    fn ham_deck_term_is_the_choices_paragraph() {
1666        let source = ham_card(9, HAM_HISTORY);
1667        let html = render_page(&source);
1668        assert_eq!(page_term_lines(&html), vec![3], "{html}");
1669        assert_eq!(
1670            scan_terms(&source)
1671                .iter()
1672                .map(|t| t.line)
1673                .collect::<Vec<_>>(),
1674            vec![3]
1675        );
1676        assert!(
1677            html.contains("<p data-mbr-line=\"1\"><strong>T1-A09"),
1678            "{html}"
1679        );
1680        assert_bullets_inside_definitions(&html, &["2026-10-07 14:53 - Good"]);
1681        // The bold question is not the term, so it is not a valid `expected`.
1682        let question = "**T1-A09: Who selects a Frequency Coordinator?**";
1683        assert_eq!(
1684            append_review(&source, 1, question, Rating::Good, at(13, 45)),
1685            Err(FlashcardPatchError::NotATerm { line: 1 })
1686        );
1687    }
1688
1689    #[test]
1690    fn ham_deck_appends_one_bullet_after_the_history() {
1691        let source = ham_card(9, HAM_HISTORY);
1692        let patch = ham_append(&source, 3, Rating::Good, 45);
1693        assert_eq!(
1694            patch.source,
1695            format!("{source}  * 2026-10-06 13:45 - Good\n"),
1696            "only the new bullet, after the last one"
1697        );
1698        assert_eq!(patch.inserted, vec!["  * 2026-10-06 13:45 - Good"]);
1699        assert_eq!(patch.inserted_at, 16);
1700        assert_eq!(patch.entry_line(), 16);
1701        assert!(patch.source.contains(&format!("\n{HAM_REMEMBER}")));
1702        assert!(
1703            !patch.source.contains("\n\n  *"),
1704            "no blank before a bullet"
1705        );
1706        let html = render_page(&patch.source);
1707        assert_eq!(page_term_lines(&html), vec![3]);
1708        assert_bullets_inside_definitions(
1709            &html,
1710            &["2026-10-07 14:53 - Good", "2026-10-06 13:45 - Good"],
1711        );
1712    }
1713
1714    #[test]
1715    fn ham_deck_first_review_creates_history_after_memory_aids_bullet() {
1716        let source = ham_card(9, "");
1717        let patch = ham_append(&source, 3, Rating::Hard, 45);
1718        assert_eq!(
1719            patch.source,
1720            format!("{source}: ___Review History___\n  * 2026-10-06 13:45 - Hard\n")
1721        );
1722        assert_eq!(patch.inserted_at, 14, "right after the Remember bullet");
1723        let html = render_page(&patch.source);
1724        assert_bullets_inside_definitions(&html, &["2026-10-06 13:45 - Hard"]);
1725        assert!(
1726            html.contains(
1727                "<dd data-mbr-line=\"14\"><em><strong>Review History</strong></em>\n<ul>"
1728            ),
1729            "{html}"
1730        );
1731    }
1732
1733    #[test]
1734    fn ham_deck_with_several_history_entries() {
1735        let history = concat!(
1736            ": ___Review History___\n",
1737            "  * 2026-10-01 08:00 - Again\n",
1738            "  * 2026-10-03 09:10 - Hard\n",
1739            "  * 2026-10-07 14:53 - Good\n",
1740        );
1741        let source = ham_card(9, history);
1742        let patch = ham_append(&source, 3, Rating::Easy, 45);
1743        assert_eq!(
1744            patch.source,
1745            format!("{source}  * 2026-10-06 13:45 - Easy\n")
1746        );
1747        assert_eq!(patch.inserted_at, 18);
1748        assert_bullets_inside_definitions(&render_page(&patch.source), &["13:45 - Easy"]);
1749    }
1750
1751    /// Five sequential reviews of the user's card: each one only appends,
1752    /// the `Memory aids` bullet never moves, and the page keeps one `<dl>`.
1753    #[test]
1754    fn ham_deck_is_stable_over_repeated_reviews() {
1755        for start in ["", HAM_HISTORY] {
1756            let original = ham_card(9, start);
1757            let mut source = original.clone();
1758            let mut expected_tail = if start.is_empty() {
1759                ": ___Review History___\n".to_string()
1760            } else {
1761                start.to_string()
1762            };
1763            let ratings = [
1764                Rating::Again,
1765                Rating::Hard,
1766                Rating::Good,
1767                Rating::Easy,
1768                Rating::Good,
1769            ];
1770            for (minute, rating) in (0u32..).zip(ratings) {
1771                let patch = ham_append(&source, 3, rating, minute);
1772                source = patch.source;
1773                expected_tail.push_str(&format!(
1774                    "  * 2026-10-06 13:{minute:02} - {}\n",
1775                    rating.label()
1776                ));
1777                assert_eq!(source, format!("{}{expected_tail}", ham_card(9, "")));
1778                let html = render_page(&source);
1779                assert_eq!(page_term_lines(&html), vec![3]);
1780                assert_eq!(assert_bullets_inside_definitions(&html, &[]), 1);
1781                assert_eq!(
1782                    html.matches("Review History").count(),
1783                    1,
1784                    "one history: {html}"
1785                );
1786            }
1787            assert!(source.starts_with(&original[..original.len() - start.len()]));
1788        }
1789    }
1790
1791    /// Three cards separated by blank lines, the first and last with
1792    /// history, the middle one without: each review lands under its own card,
1793    /// addressed by the `<dt>` line the page renders after the previous write.
1794    #[test]
1795    fn ham_deck_several_cards_in_a_row() {
1796        let cards = [
1797            ham_card(1, HAM_HISTORY),
1798            ham_card(2, ""),
1799            ham_card(3, HAM_HISTORY),
1800        ];
1801        let mut source = cards.join("\n");
1802        let mut cards = cards.to_vec();
1803        // Review middle, last, first, middle again.
1804        for (minute, card) in [1usize, 2, 0, 1].into_iter().enumerate() {
1805            let lines = page_term_lines(&render_page(&source));
1806            assert_eq!(lines.len(), 3, "{source}");
1807            assert_eq!(
1808                lines,
1809                scan_terms(&source)
1810                    .iter()
1811                    .map(|t| t.line)
1812                    .collect::<Vec<_>>()
1813            );
1814            let minute = u32::try_from(minute).unwrap();
1815            let patch = ham_append(&source, lines[card], Rating::Good, minute);
1816            let bullet = format!("  * 2026-10-06 13:{minute:02} - Good\n");
1817            if !cards[card].contains("Review History") {
1818                cards[card].push_str(": ___Review History___\n");
1819            }
1820            cards[card].push_str(&bullet);
1821            source = patch.source;
1822            assert_eq!(source, cards.join("\n"));
1823            let html = render_page(&source);
1824            assert_eq!(assert_bullets_inside_definitions(&html, &[]), 3, "{html}");
1825        }
1826        // Every card's term moved only by the lines inserted above it.
1827        let first_len = u32::try_from(cards[0].lines().count()).unwrap();
1828        let second_len = u32::try_from(cards[1].lines().count()).unwrap();
1829        assert_eq!(
1830            page_term_lines(&render_page(&source)),
1831            vec![
1832                1 + HAM_TERM_OFFSET,
1833                first_len + 2 + HAM_TERM_OFFSET,
1834                first_len + second_len + 3 + HAM_TERM_OFFSET,
1835            ]
1836        );
1837    }
1838
1839    #[test]
1840    fn ham_deck_crlf() {
1841        let source = ham_card(9, HAM_HISTORY).replace('\n', "\r\n");
1842        let patch = ham_append(&source, 3, Rating::Good, 45);
1843        assert_eq!(
1844            patch.source,
1845            format!("{source}  * 2026-10-06 13:45 - Good\r\n")
1846        );
1847        let fresh = ham_card(9, "").replace('\n', "\r\n");
1848        let patch = ham_append(&fresh, 3, Rating::Good, 45);
1849        assert_eq!(
1850            patch.source,
1851            format!("{fresh}: ___Review History___\r\n  * 2026-10-06 13:45 - Good\r\n")
1852        );
1853        assert_bullets_inside_definitions(&render_page(&patch.source), &["13:45 - Good"]);
1854    }
1855
1856    #[test]
1857    fn ham_deck_without_trailing_newline() {
1858        let with = ham_card(9, HAM_HISTORY);
1859        let source = with.trim_end_matches('\n');
1860        let patch = ham_append(source, 3, Rating::Good, 45);
1861        assert_eq!(
1862            patch.source,
1863            format!("{source}\n  * 2026-10-06 13:45 - Good")
1864        );
1865        let fresh = ham_card(9, "");
1866        let fresh = fresh.trim_end_matches('\n');
1867        let patch = ham_append(fresh, 3, Rating::Good, 45);
1868        assert_eq!(
1869            patch.source,
1870            format!("{fresh}\n: ___Review History___\n  * 2026-10-06 13:45 - Good")
1871        );
1872        assert_bullets_inside_definitions(&render_page(&patch.source), &["13:45 - Good"]);
1873    }
1874
1875    /// The next card's question glued to the history bullet (no blank line)
1876    /// is, before any write, a lazy continuation of that bullet — the page
1877    /// shows it inside the history, and both cards share one `<dl>`. The write
1878    /// must not change what the page shows beyond adding the entry: the second
1879    /// card's term stays a term, and nothing is outdented or separated.
1880    #[test]
1881    fn ham_deck_second_card_glued_after_the_history() {
1882        for first in [HAM_HISTORY, ""] {
1883            let source = format!("{}{}", ham_card(1, first), ham_card(2, HAM_HISTORY));
1884            let html = render_page(&source);
1885            let lines = page_term_lines(&html);
1886            assert_eq!(lines.len(), 2, "{html}");
1887            // Already one `<dl>`: the glued question continues the bullet.
1888            let lists = assert_bullets_inside_definitions(&html, &[]);
1889            assert_eq!(lists, 1, "{html}");
1890            for &line in &lines {
1891                let patch = ham_append(&source, line, Rating::Good, 45);
1892                let after = render_page(&patch.source);
1893                assert_eq!(
1894                    page_term_lines(&after).len(),
1895                    2,
1896                    "a card was lost: {}",
1897                    patch.source
1898                );
1899                assert!(after.contains("2026-10-06 13:45 - Good"), "{after}");
1900                assert_eq!(
1901                    patch.source.matches(HAM_REMEMBER).count(),
1902                    2,
1903                    "{}",
1904                    patch.source
1905                );
1906                assert_eq!(
1907                    assert_bullets_inside_definitions(&after, &[]),
1908                    lists,
1909                    "{after}"
1910                );
1911            }
1912        }
1913    }
1914
1915    // ---- T1-B11: the second damaged card ------------------------------------
1916
1917    /// T1-B11 as it presumably was before it was damaged: two indented
1918    /// `Memory aids` bullets, then `{history}`.
1919    fn b11_card(history: &str) -> String {
1920        format!(
1921            concat!(
1922                "**T1-B11: What is the maximum peak envelope power output for Technician class operators in their HF band segments?**\n",
1923                "\n",
1924                "A. 200 watts\n",
1925                "B. 100 watts\n",
1926                "C. 50 watts\n",
1927                "D. 10 watts\n",
1928                ": **A.** 200 watts\n",
1929                ": Technician-class operators are limited to 200 watts PEP (Peak Envelope Power) on any portion of an HF band that they are authorized to use. This is an FCC regulation that must be memorized for the exam.\n",
1930                ": Also remember that FCC rule 97.313 requires stations to use the minimum transmitter power necessary to carry out the desired communications, even though the maximum allowed for Technician HF segments is 200 W PEP.\n",
1931                ": Memory aids:\n",
1932                "  * \"HF\" has 2 letters → 2-hundred watts (200 W)\n",
1933                "  * The word \"maximum\" corresponds to the largest numeric choice\n",
1934                "{history}",
1935            ),
1936            history = history,
1937        )
1938    }
1939
1940    /// The card exactly as found on disk.
1941    const B11_DAMAGED: &str = concat!(
1942        "**T1-B11: What is the maximum peak envelope power output for Technician class operators in their HF band segments?**\n",
1943        "\n",
1944        "A. 200 watts\n",
1945        "B. 100 watts\n",
1946        "C. 50 watts\n",
1947        "D. 10 watts\n",
1948        ": **A.** 200 watts\n",
1949        ": Technician-class operators are limited to 200 watts PEP (Peak Envelope Power) on any portion of an HF band that they are authorized to use. This is an FCC regulation that must be memorized for the exam.\n",
1950        ": Also remember that FCC rule 97.313 requires stations to use the minimum transmitter power necessary to carry out the desired communications, even though the maximum allowed for Technician HF segments is 200 W PEP.\n",
1951        ": Memory aids:\n",
1952        "\n",
1953        "* \"HF\" has 2 letters → 2-hundred watts (200 W)\n",
1954        "* The word \"maximum\" corresponds to the largest numeric choice\n",
1955        "  : ___Review History___\n",
1956        "* 2026-10-07 14:54 - Again\n",
1957    );
1958    const B11_TERM: &str = "A. 200 watts";
1959
1960    fn b11_append(source: &str, minute: u32) -> ReviewPatch {
1961        let patch = append_review(source, 3, B11_TERM, Rating::Again, at(14, minute))
1962            .unwrap_or_else(|e| panic!("{e:?}\n{source}"));
1963        assert_pure_insert(source, &patch);
1964        patch
1965    }
1966
1967    /// The writer's own first review of a card whose last answer ends in an
1968    /// indented bullet: the label takes the answer's *first* line's `: `
1969    /// prefix (column 0), never the bullet's indent, and the entry is indented
1970    /// under it — not `  : ___Review History___` and `* …` at column 0, which
1971    /// is the damage found on disk.
1972    #[test]
1973    fn b11_first_review_labels_at_column_zero_after_the_bullets() {
1974        let source = b11_card("");
1975        let patch = b11_append(&source, 54);
1976        assert_eq!(
1977            patch.inserted,
1978            vec![": ___Review History___", "  * 2026-10-06 14:54 - Again"]
1979        );
1980        assert_eq!(patch.inserted_at, 13, "after the second Memory aids bullet");
1981        assert_eq!(
1982            patch.source,
1983            format!("{source}: ___Review History___\n  * 2026-10-06 14:54 - Again\n")
1984        );
1985        let html = render_page(&patch.source);
1986        assert_bullets_inside_definitions(
1987            &html,
1988            &["2 letters", "largest numeric", "14:54 - Again"],
1989        );
1990        assert!(!patch.source.contains("\n  : "), "{}", patch.source);
1991        assert!(!patch.source.contains("\n* "), "{}", patch.source);
1992        assert!(!patch.source.contains(":\n\n"), "{}", patch.source);
1993    }
1994
1995    /// From either original form — no history yet, or one with entries — no
1996    /// number of reviews produces the damaged shape.
1997    #[test]
1998    fn b11_repeated_reviews_never_produce_the_damage() {
1999        for history in ["", ": ___Review History___\n  * 2026-10-05 09:00 - Good\n"] {
2000            let mut source = b11_card(history);
2001            for minute in 0..5 {
2002                source = b11_append(&source, minute).source;
2003                assert!(!source.contains("\n  : "), "{source}");
2004                assert!(!source.contains("\n* "), "{source}");
2005                assert!(!source.contains(":\n\n"), "{source}");
2006                let html = render_page(&source);
2007                assert_eq!(page_term_lines(&html), vec![3]);
2008                assert_eq!(assert_bullets_inside_definitions(&html, &[]), 1, "{html}");
2009                assert_eq!(html.matches("Review History").count(), 1, "{html}");
2010            }
2011            let entries = (0..5)
2012                .map(|m| format!("  * 2026-10-06 14:{m:02} - Again\n"))
2013                .collect::<String>();
2014            let label = if history.is_empty() {
2015                ": ___Review History___\n"
2016            } else {
2017                ""
2018            };
2019            assert_eq!(source, format!("{}{label}{entries}", b11_card(history)));
2020        }
2021    }
2022
2023    /// On the damaged file the card has no history the parser can see: the
2024    /// blank line ends the `<dl>` after `: Memory aids:`, and the bullets, the
2025    /// indented label (a lazy continuation of the second bullet) and the old
2026    /// entry are an ordinary list after it. The writer therefore treats the
2027    /// card as never reviewed and creates a fresh history *inside* the `<dl>`
2028    /// right after `: Memory aids:` — a pure insert, so it neither moves nor
2029    /// repairs the stray list, and the old entry stays outside (unread by
2030    /// the deck, which reads history from the `<dd>`).
2031    ///
2032    /// The indented label also makes the second stray bullet a *nested*
2033    /// definition list (term `The word "maximum" …`, line 13). Nested lists
2034    /// are not cards on either side — `scan_terms` and the deck's `dom.ts`
2035    /// both take top-level lists only — so a review addressed there is
2036    /// `NotATerm`.
2037    #[test]
2038    fn b11_damaged_file_gets_a_fresh_history_inside_the_list() {
2039        let html = render_page(B11_DAMAGED);
2040        assert_eq!(page_term_lines(&html), vec![3, 13], "{html}");
2041        assert_eq!(
2042            scan_terms(B11_DAMAGED)
2043                .iter()
2044                .map(|t| t.line)
2045                .collect::<Vec<_>>(),
2046            vec![3]
2047        );
2048        let nested = B11_DAMAGED.lines().nth(12).unwrap();
2049        assert_eq!(
2050            append_review(B11_DAMAGED, 13, nested, Rating::Good, at(14, 54)),
2051            Err(FlashcardPatchError::NotATerm { line: 13 })
2052        );
2053        let dl_end = html.find("</dl>").expect("a <dl>");
2054        assert!(dl_end < html.find("2 letters").unwrap(), "{html}");
2055        assert!(!html[..dl_end].contains("Review History"), "{html}");
2056
2057        let patch = b11_append(B11_DAMAGED, 54);
2058        assert_eq!(patch.inserted_at, 11, "right after `: Memory aids:`");
2059        assert_eq!(
2060            patch.inserted,
2061            vec![": ___Review History___", "  * 2026-10-06 14:54 - Again"]
2062        );
2063        let html = render_page(&patch.source);
2064        let dl_end = html.find("</dl>").expect("a <dl>");
2065        assert!(html[..dl_end].contains("<em><strong>Review History</strong></em>\n<ul>"));
2066        assert!(html[..dl_end].contains("14:54 - Again"), "{html}");
2067        // The old, damaged block is untouched and still outside.
2068        assert!(
2069            html[dl_end..].contains("2026-10-07 14:54 - Again"),
2070            "{html}"
2071        );
2072        // A second review lands in the new history, not the stray list.
2073        let second = b11_append(&patch.source, 55);
2074        assert_eq!(second.inserted, vec!["  * 2026-10-06 14:55 - Again"]);
2075        assert_eq!(second.inserted_at, 13);
2076    }
2077
2078    // ---- properties ---------------------------------------------------------
2079
2080    /// One generated card: its term, its answers and an optional history.
2081    #[derive(Debug, Clone)]
2082    struct GenCard {
2083        loose: bool,
2084        /// No blank line between this card's term and the previous card.
2085        glued: bool,
2086        /// Spaces in front of a glued term (0–3; a term may be indented).
2087        indent: usize,
2088        answers: Vec<u8>,
2089        history: Option<(char, Vec<u8>)>,
2090    }
2091
2092    impl GenCard {
2093        /// Whether a term glued straight after this card is a term of its
2094        /// own. Only when the card's last definition ends in a closed fence
2095        /// (answer kind 3, no history): anything ending in a paragraph — a
2096        /// one-line answer, a list item, a history label or bullet — takes
2097        /// the glued line as a lazy continuation instead.
2098        fn ends_in_a_fence(&self) -> bool {
2099            self.history.is_none() && self.answers.last().is_some_and(|kind| kind % 5 == 3)
2100        }
2101    }
2102
2103    /// Answer shapes, by index — single line, multi-line continuation, nested
2104    /// list, code fence holding a fake definition, and an inline-code answer.
2105    fn answer_text(kind: u8, n: usize) -> String {
2106        match kind % 5 {
2107            0 => format!(": answer {n}"),
2108            1 => format!(": answer {n} line one\n  and line two"),
2109            2 => format!(": list {n}:\n  * a{n}\n  * b{n}"),
2110            3 => format!(": code {n}\n\n  ```\n  : fake {n}\n  Fake term?\n  ```"),
2111            _ => format!(": `code {n}` answer"),
2112        }
2113    }
2114
2115    fn history_text(marker: char, entries: &[u8]) -> String {
2116        let mut text = String::from(": ___Review History___");
2117        for (i, kind) in entries.iter().enumerate() {
2118            let rating = ["Again", "Fail", "Hard", "Good", "Easy", "garbage"][*kind as usize % 6];
2119            text.push_str(&format!(
2120                "\n  {marker} 2026-09-{:02} 10:00 - {rating}",
2121                i + 1
2122            ));
2123        }
2124        text
2125    }
2126
2127    fn gen_card() -> impl Strategy<Value = GenCard> {
2128        (
2129            any::<bool>(),
2130            any::<bool>(),
2131            0usize..4,
2132            prop::collection::vec(0u8..5, 1..4),
2133            prop::option::of((
2134                prop::sample::select(vec!['*', '-']),
2135                prop::collection::vec(0u8..6, 0..4),
2136            )),
2137        )
2138            .prop_map(|(loose, glued, indent, answers, history)| GenCard {
2139                loose,
2140                glued,
2141                indent,
2142                answers,
2143                history,
2144            })
2145    }
2146
2147    /// A document: optional frontmatter, then deflists (separated by a
2148    /// paragraph or a fenced block of fake cards), with chosen line endings.
2149    fn gen_document() -> impl Strategy<Value = (String, Vec<u32>)> {
2150        (
2151            any::<bool>(),
2152            prop::collection::vec(prop::collection::vec(gen_card(), 1..4), 1..4),
2153            any::<bool>(),
2154            any::<bool>(),
2155        )
2156            .prop_map(|(frontmatter, lists, crlf, trailing_newline)| {
2157                let mut blocks: Vec<String> = Vec::new();
2158                if frontmatter {
2159                    blocks.push("---\ntype: flashcard\n---".to_string());
2160                }
2161                let mut term_index = 0usize;
2162                for (list_index, cards) in lists.iter().enumerate() {
2163                    if list_index > 0 {
2164                        blocks.push(if list_index % 2 == 0 {
2165                            "Some prose between lists.".to_string()
2166                        } else {
2167                            "```\nFake?\n: fake\n```".to_string()
2168                        });
2169                    }
2170                    for (card_index, card) in cards.iter().enumerate() {
2171                        term_index += 1;
2172                        let separator = if card.loose { "\n\n" } else { "\n" };
2173                        let mut definitions: Vec<String> = card
2174                            .answers
2175                            .iter()
2176                            .map(|kind| answer_text(*kind, term_index))
2177                            .collect();
2178                        if let Some((marker, entries)) = &card.history {
2179                            definitions.push(history_text(*marker, entries));
2180                        }
2181                        let previous = card_index.checked_sub(1).map(|i| &cards[i]);
2182                        let glued = card.glued && previous.is_some();
2183                        // A glued line that the parser will read as a lazy
2184                        // continuation is not a term, so it must not be named
2185                        // like one: `terms` below is the generator's own
2186                        // prediction, checked against the scanner. After a
2187                        // fence, two or more spaces reach the definition's
2188                        // content column (2), making the line a paragraph
2189                        // inside it rather than a term.
2190                        let indent = if glued { card.indent } else { 0 };
2191                        let is_term = !glued
2192                            || (previous.is_some_and(GenCard::ends_in_a_fence) && indent < 2);
2193                        let name = if is_term { "Term" } else { "Glued" };
2194                        let card_text = format!(
2195                            "{}{name} {term_index}?{separator}{}",
2196                            " ".repeat(indent),
2197                            definitions.join(separator)
2198                        );
2199                        match blocks.last_mut() {
2200                            Some(last) if glued => {
2201                                last.push('\n');
2202                                last.push_str(&card_text);
2203                            }
2204                            _ => blocks.push(card_text),
2205                        }
2206                    }
2207                }
2208                let mut text = blocks.join("\n\n");
2209                if trailing_newline {
2210                    text.push('\n');
2211                }
2212                let terms: Vec<u32> = text
2213                    .split('\n')
2214                    .enumerate()
2215                    .filter(|(_, line)| line.trim_start().starts_with("Term "))
2216                    .map(|(i, _)| i as u32 + 1)
2217                    .collect();
2218                if crlf {
2219                    text = text.replace('\n', "\r\n");
2220                }
2221                (text, terms)
2222            })
2223    }
2224
2225    /// What a card means, independent of where its bytes are: the term line's
2226    /// text, each answer's trimmed source, and the history item count.
2227    fn outline(source: &str) -> Vec<(String, Vec<String>, Option<usize>)> {
2228        scan_terms(source)
2229            .iter()
2230            .map(|term| {
2231                let term_text =
2232                    line_containing(source, line_span(source, term.line).unwrap().start);
2233                let answers = term
2234                    .definitions
2235                    .iter()
2236                    .filter(|d| !d.history)
2237                    .map(|d| source[d.range.clone()].trim_end().to_string())
2238                    .collect();
2239                let history = term.definitions.iter().find(|d| d.history).map(|d| {
2240                    let text = &source[d.range.clone()];
2241                    text.lines()
2242                        .filter(|line| line.trim_start().starts_with(['*', '-']))
2243                        .count()
2244                });
2245                (term_text.to_string(), answers, history)
2246            })
2247            .collect()
2248    }
2249
2250    proptest! {
2251        /// Appending to one card changes nothing but that card's history,
2252        /// which gains exactly one entry — and the new entry is findable.
2253        #[test]
2254        fn append_adds_exactly_one_history_entry(
2255            (source, terms) in gen_document(),
2256            pick in any::<prop::sample::Index>(),
2257            rating in prop::sample::select(vec![Rating::Again, Rating::Hard, Rating::Good, Rating::Easy]),
2258        ) {
2259            let before = outline(&source);
2260            prop_assert_eq!(before.len(), terms.len(), "generator and scanner agree: {:?}", source);
2261
2262            let target = pick.index(terms.len());
2263            let line = terms[target];
2264            let expected = line_containing(&source, line_span(&source, line).unwrap().start).to_string();
2265            let patch = append_review(&source, line, &expected, rating, at(13, 45)).unwrap();
2266            let after = outline(&patch.source);
2267
2268            prop_assert_eq!(after.len(), before.len());
2269            for (i, (b, a)) in before.iter().zip(&after).enumerate() {
2270                prop_assert_eq!(&b.0, &a.0, "term text");
2271                prop_assert_eq!(&b.1, &a.1, "answers of term {}", i);
2272                if i == target {
2273                    prop_assert_eq!(a.2, Some(b.2.unwrap_or(0) + 1), "{}", patch.source);
2274                } else {
2275                    prop_assert_eq!(b.2, a.2);
2276                }
2277            }
2278
2279            // The inserted lines are where the patch says they are.
2280            let lines: Vec<&str> = patch.source.split('\n').map(|l| l.trim_end_matches('\r')).collect();
2281            for (offset, inserted) in patch.inserted.iter().enumerate() {
2282                prop_assert_eq!(lines[patch.inserted_at as usize - 1 + offset], inserted.as_str());
2283            }
2284            prop_assert!(lines[patch.entry_line() as usize - 1].ends_with(&patch.entry));
2285
2286            // Terminator style and trailing-newline presence are preserved.
2287            let crlf = source.contains("\r\n");
2288            prop_assert_eq!(crlf, patch.source.contains("\r\n"));
2289            if crlf {
2290                prop_assert_eq!(
2291                    patch.source.matches('\n').count(),
2292                    patch.source.matches("\r\n").count(),
2293                    "no bare LF in a CRLF file"
2294                );
2295            }
2296            prop_assert_eq!(source.ends_with('\n'), patch.source.ends_with('\n'));
2297            prop_assert_eq!(
2298                patch.source.split('\n').count(),
2299                source.split('\n').count() + patch.inserted.len()
2300            );
2301        }
2302
2303        /// Arbitrary input never panics.
2304        #[test]
2305        fn append_never_panics(source in "(?s).{0,200}", line in 0u32..20, expected in ".{0,20}") {
2306            let _ = append_review(&source, line, &expected, Rating::Good, at(1, 2));
2307        }
2308
2309        /// Arbitrary input made of markdown-ish pieces never panics either, and
2310        /// any success re-parses with the term still in place.
2311        #[test]
2312        fn append_on_markdownish_input_never_panics(
2313            pieces in prop::collection::vec(
2314                prop::sample::select(vec![
2315                    "Q?", ": a", ": ___Review History___", "  * x", "  - 2026-01-01 00:00 - Good",
2316                    "", "```", "> q", "- item", "  ```", ":", "  : nested", "\t* tab", "**Review History**",
2317                ]),
2318                0..24,
2319            ),
2320            line in 1u32..24,
2321        ) {
2322            let source = pieces.join("\n");
2323            let expected = source.split('\n').nth(line as usize - 1).unwrap_or_default().to_string();
2324            if let Ok(patch) = append_review(&source, line, &expected, Rating::Easy, at(1, 2)) {
2325                prop_assert!(scan_terms(&patch.source).iter().any(|t| t.line == line));
2326            }
2327        }
2328    }
2329}