1use 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
58pub const HISTORY_LABEL: &str = "___Review History___";
64
65const HISTORY_TEXT: &str = "review history";
67
68const ENTRY_TIME_FORMAT: &str = "%Y-%m-%d %H:%M";
71
72pub const REVIEW_TIME_WINDOW_HOURS: i64 = 26;
79
80pub 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#[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 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
145pub 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#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ReviewPatch {
157 pub source: String,
159 pub entry: String,
161 pub inserted_at: u32,
163 pub inserted: Vec<String>,
171}
172
173impl ReviewPatch {
174 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
186pub 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 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 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
354fn could_be_absorbed(line: &str) -> bool {
363 let content = line.trim_start_matches([' ', '\t']);
364 !content.is_empty() && !content.starts_with(':')
365}
366
367fn 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 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
402pub fn is_history_label(text: &str) -> bool {
405 text.trim().eq_ignore_ascii_case(HISTORY_TEXT)
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
414struct Term {
415 line: u32,
417 definitions: Vec<Definition>,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq)]
422struct Definition {
423 range: Range<usize>,
424 history: bool,
426 last_item: Option<Range<usize>>,
428}
429
430#[derive(Debug)]
432enum Label {
433 Pending,
435 Capturing {
437 depth: usize,
438 text: String,
439 },
440 Decided(bool),
441}
442
443const CONTENT_DEPTH: usize = 2;
446
447#[derive(Debug)]
449struct DefinitionScan {
450 range: Range<usize>,
451 label: Label,
452 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 fn start(&mut self, tag: &Tag<'_>, depth: usize, range: &Range<usize>) {
471 if matches!(self.label, Label::Pending) {
472 self.label = match tag {
473 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 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 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 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
531fn 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
597fn line_start(source: &str, offset: usize) -> usize {
603 source[..offset]
604 .rfind('\n')
605 .map_or(0, |newline| newline + 1)
606}
607
608fn 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
617fn 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
630fn definition_prefix(source: &str, offset: usize) -> String {
639 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
655fn content_indent(prefix: &str) -> String {
658 prefix.replace(':', " ")
659}
660
661fn bullet_after(source: &str, item_start: usize, entry: &str) -> String {
664 let start = line_start(source, item_start);
665 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
687fn 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 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 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 #[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 fn utc_now() -> NaiveDateTime {
774 at(12, 0)
775 }
776
777 #[test]
778 fn review_time_accepts_any_real_time_zone() {
779 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 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", "2026-10-07 14:01", "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", "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", "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", "2026-10-32 12:00",
840 "2026-10-06 24:00",
841 "2026-10-06 12:60",
842 "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 #[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 #[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 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 #[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 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 #[test]
1067 fn second_term_in_the_same_list_and_terms_in_other_lists() {
1068 let source = concat!(
1069 "# Deck\n", "\n", "One?\n", ": 1\n", "\n", "Two?\n", ": 2\n", "\n", "A paragraph.\n", "\n", "Three?\n", ": 3\n", );
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 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 #[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 #[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 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 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 #[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 #[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 #[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 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 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 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 #[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 assert_rating_keeps_cards(source, 7);
1388 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 #[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 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 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 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 #[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 #[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 #[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 assert!(
2069 html[dl_end..].contains("2026-10-07 14:54 - Again"),
2070 "{html}"
2071 );
2072 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 #[derive(Debug, Clone)]
2082 struct GenCard {
2083 loose: bool,
2084 glued: bool,
2086 indent: usize,
2088 answers: Vec<u8>,
2089 history: Option<(char, Vec<u8>)>,
2090 }
2091
2092 impl GenCard {
2093 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 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 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 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 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 #[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 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 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 #[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 #[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}