use std::{
collections::HashSet,
path::{Path, PathBuf},
};
use thiserror::Error;
use crate::{
answer::Input,
card::{Card, Direction},
config::Strictness,
depth::Reveal,
parser::{self, ParseError},
session::{self, Order},
store::Store,
};
#[derive(Debug, Default, Clone)]
pub struct DeckSettings {
pub reveal: Option<Reveal>,
pub input: Option<Input>,
pub order: Option<Order>,
pub direction: Option<Direction>,
pub img_dir: Option<PathBuf>,
pub exam_strictness: Option<Strictness>,
pub origin: Option<String>,
}
impl DeckSettings {
pub fn from_directives(directives: &[(String, String)]) -> Self {
let mut settings = Self::default();
for (key, value) in directives {
match key.as_str() {
"reveal" => settings.reveal = Reveal::parse(value),
"input" => settings.input = Input::parse(value),
"order" => settings.order = Order::parse(value),
"direction" => settings.direction = Direction::parse(value),
"img-dir" => settings.img_dir = Some(PathBuf::from(value)),
"strictness" => settings.exam_strictness = Strictness::parse(value),
"origin" => {
let v = value.trim();
if !v.is_empty() {
settings.origin = Some(v.to_string());
}
}
_ => {}
}
}
settings
}
fn fill_from(&mut self, defaults: &DeckSettings) {
self.reveal = self.reveal.or(defaults.reveal);
self.input = self.input.or(defaults.input);
self.order = self.order.or(defaults.order);
self.direction = self.direction.or(defaults.direction);
self.img_dir = self.img_dir.clone().or_else(|| defaults.img_dir.clone());
self.exam_strictness = self.exam_strictness.or(defaults.exam_strictness);
self.origin = self.origin.clone().or_else(|| defaults.origin.clone());
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeckState {
NotStarted,
Started,
ExamDue,
Finished,
}
#[derive(Debug)]
pub struct Deck {
pub path: PathBuf,
pub subject: String,
pub cards: Vec<Card>,
pub links: Vec<String>,
pub requires: Vec<String>,
pub sources: Vec<String>,
pub settings: DeckSettings,
pub title: Option<String>,
pub trace: Option<String>,
}
#[derive(Debug, Error)]
pub enum DeckError {
#[error("{path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: ParseError,
},
#[error("{path}: file name is not valid UTF-8")]
InvalidFileName { path: PathBuf },
}
impl Deck {
pub fn load(path: impl AsRef<Path>) -> Result<Self, DeckError> {
Self::load_with_defaults(path, &DeckSettings::default())
}
pub fn load_with_defaults(
path: impl AsRef<Path>,
defaults: &DeckSettings,
) -> Result<Self, DeckError> {
let path = path.as_ref().to_path_buf();
let subject = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| DeckError::InvalidFileName { path: path.clone() })?
.to_string();
let text = std::fs::read_to_string(&path).map_err(|source| DeckError::Io {
path: path.clone(),
source,
})?;
let mut cards = parser::parse_str(&subject, &text).map_err(|source| DeckError::Parse {
path: path.clone(),
source,
})?;
let links = parser::parse_links(&text);
let requires = parser::parse_requires(&text);
let sources = parser::parse_sources(&text);
let title = parser::parse_title(&text);
let trace = parser::parse_trace(&text);
let mut settings = DeckSettings::from_directives(&parser::parse_directives(&text));
settings.fill_from(defaults);
for card in &mut cards {
card.reveal = card.reveal.or(settings.reveal);
card.input = card.input.or(settings.input);
}
let base_dir = image_base_dir(&path, settings.img_dir.as_deref());
for card in &mut cards {
card.image = card.image.take().map(|p| resolve_image(&base_dir, p));
card.image_back = card.image_back.take().map(|p| resolve_image(&base_dir, p));
}
let mut expanded = Vec::with_capacity(cards.len());
for card in cards {
let direction = card.direction.or(settings.direction).unwrap_or_default();
if card.hash_lines.is_some() || direction == Direction::Forward {
expanded.push(card);
} else {
let reversed = card.reversed();
match direction {
Direction::Reverse => expanded.push(reversed),
Direction::Both => {
expanded.push(card);
expanded.push(reversed);
}
Direction::Forward => unreachable!("handled above"),
}
}
}
let cards = expanded;
Ok(Self {
path,
subject,
cards,
links,
requires,
sources,
settings,
title,
trace,
})
}
pub fn is_trace(&self) -> bool {
self.trace.is_some()
}
pub fn has_exam(&self) -> bool {
self.is_trace() || !self.sources.is_empty()
}
pub fn display_name(&self) -> String {
self.title
.clone()
.or_else(|| self.trace.clone())
.unwrap_or_else(|| {
self.subject
.strip_suffix(".txt")
.unwrap_or(&self.subject)
.to_string()
})
}
pub fn state(&self, store: &Store) -> DeckState {
let total = self.cards.len();
if total == 0 {
return DeckState::NotStarted;
}
if store.deck_mastered(&self.subject) {
return DeckState::Finished;
}
let gated = self.cards.iter().all(|c| session::has_graduated(c, store));
if gated {
if self.has_exam() {
DeckState::ExamDue
} else {
DeckState::Finished
}
} else if self.cards.iter().all(|c| store.get(c.id()).is_none()) {
DeckState::NotStarted
} else {
DeckState::Started
}
}
pub fn reference_links(&self) -> Vec<String> {
let mut out = self.links.clone();
for src in &self.sources {
if is_url(src) && !out.contains(src) {
out.push(src.clone());
}
}
out
}
pub fn source_root(&self) -> Option<PathBuf> {
if let Some(origin) = &self.settings.origin {
return Some(PathBuf::from(origin));
}
let deck_dir = self.path.parent().unwrap_or_else(|| Path::new("."));
if self.is_frozen()
&& let Ok(ws) = crate::workspace::Workspace::load(deck_dir)
&& let Some(origin) = ws.settings.origin
{
return Some(PathBuf::from(origin));
}
crate::trace::project_root(&self.sources, deck_dir)
}
pub fn is_frozen(&self) -> bool {
self.sources
.first()
.is_some_and(|s| s == crate::trace::SNAPSHOT_DIR)
}
pub fn duplicates(&self) -> Vec<(&Card, &Card)> {
let mut seen: std::collections::HashMap<u64, &Card> = Default::default();
let mut dups = Vec::new();
for card in &self.cards {
if let Some(first) = seen.insert(card.id(), card) {
dups.push((first, card));
seen.insert(card.id(), first);
}
}
dups
}
}
pub fn resolve_dep(
req: &str,
decks_dir: Option<&Path>,
requiring_dir: Option<&Path>,
) -> Option<PathBuf> {
let with_txt = |p: &Path| -> PathBuf {
if p.extension().is_some() {
p.to_path_buf()
} else {
p.with_extension("txt")
}
};
let mut candidates = vec![PathBuf::from(req), with_txt(Path::new(req))];
for dir in [requiring_dir, decks_dir].into_iter().flatten() {
candidates.push(dir.join(req));
candidates.push(with_txt(&dir.join(req)));
}
candidates.into_iter().find(|p| p.is_file())
}
pub fn is_locked(deck: &Deck, decks_dir: Option<&Path>, store: &Store) -> bool {
fn prereqs_finished(
deck: &Deck,
decks_dir: Option<&Path>,
store: &Store,
visited: &mut HashSet<PathBuf>,
) -> bool {
for req in &deck.requires {
let Some(path) = resolve_dep(req, decks_dir, deck.path.parent()) else {
continue; };
let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if !visited.insert(key) {
continue; }
let Ok(prereq) = Deck::load(&path) else {
continue; };
if prereq.has_exam() && prereq.state(store) != DeckState::Finished {
return false;
}
if !prereqs_finished(&prereq, decks_dir, store, visited) {
return false;
}
}
true
}
!prereqs_finished(deck, decks_dir, store, &mut HashSet::new())
}
pub fn nongating_prerequisites(deck: &Deck) -> Vec<String> {
if !deck.has_exam() {
return Vec::new();
}
let dir = deck.path.parent();
let mut out = Vec::new();
for req in &deck.requires {
let sourceless = resolve_dep(req, dir, dir)
.and_then(|path| Deck::load(&path).ok())
.is_some_and(|prereq| !prereq.has_exam());
if sourceless {
out.push(req.clone());
}
}
out
}
pub(crate) fn is_url(s: &str) -> bool {
s.starts_with("http://") || s.starts_with("https://")
}
pub fn dependents(target: &Path, decks_dir: &Path) -> Vec<String> {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let target = canon(target);
let mut names = Vec::new();
let Ok(entries) = std::fs::read_dir(decks_dir) else {
return names;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("txt") {
continue;
}
let Ok(deck) = Deck::load(&path) else {
continue;
};
let requires_target = deck.requires.iter().any(|req| {
resolve_dep(req, Some(decks_dir), path.parent())
.is_some_and(|dep| canon(&dep) == target)
});
if requires_target {
names.push(deck.subject);
}
}
names.sort();
names
}
fn image_base_dir(deck_path: &Path, img_dir: Option<&Path>) -> PathBuf {
let deck_dir = deck_path.parent().unwrap_or_else(|| Path::new("."));
match img_dir {
Some(dir) if dir.is_absolute() => dir.to_path_buf(),
Some(dir) => deck_dir.join(dir),
None => deck_dir.to_path_buf(),
}
}
fn resolve_image(base: &Path, image: PathBuf) -> PathBuf {
if image.is_absolute() {
image
} else {
base.join(image)
}
}
pub fn append_note(path: &Path, front_line: usize, notes: &[String]) -> Result<(), DeckError> {
if notes.is_empty() {
return Ok(());
}
let io_err = |source| DeckError::Io {
path: path.to_path_buf(),
source,
};
let text = std::fs::read_to_string(path).map_err(io_err)?;
let new_text = insert_note_lines(&text, front_line, notes);
let tmp = path.with_extension("txt.tmp");
std::fs::write(&tmp, new_text).map_err(io_err)?;
std::fs::rename(&tmp, path).map_err(io_err)?;
Ok(())
}
pub fn append_cards(path: &Path, cards: &str) -> Result<(), DeckError> {
let cards = cards.trim_end();
if cards.is_empty() {
return Ok(());
}
let io_err = |source| DeckError::Io {
path: path.to_path_buf(),
source,
};
let existing = std::fs::read_to_string(path).map_err(io_err)?;
let mut new_text = existing.trim_end().to_string();
if !new_text.is_empty() {
new_text.push_str("\n\n");
}
new_text.push_str(cards);
new_text.push('\n');
let tmp = path.with_extension("txt.tmp");
std::fs::write(&tmp, new_text).map_err(io_err)?;
std::fs::rename(&tmp, path).map_err(io_err)?;
Ok(())
}
pub fn set_trace_checkpoints(path: &Path, cards: &str) -> Result<(), DeckError> {
let io_err = |source| DeckError::Io {
path: path.to_path_buf(),
source,
};
let existing = std::fs::read_to_string(path).map_err(io_err)?;
let new_text = replace_after_header(&existing, cards);
let tmp = path.with_extension("txt.tmp");
std::fs::write(&tmp, new_text).map_err(io_err)?;
std::fs::rename(&tmp, path).map_err(io_err)?;
Ok(())
}
fn replace_after_header(text: &str, cards: &str) -> String {
let cards = cards.trim_end();
let header = text
.lines()
.take_while(|l| !l.starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
let header = header.trim_end();
let mut out = String::new();
if !header.is_empty() {
out.push_str(header);
if !cards.is_empty() {
out.push_str("\n\n");
}
}
out.push_str(cards);
out.push('\n');
out
}
pub struct AtRewrite {
pub at: String,
pub origin: Option<String>,
}
pub fn set_trace_snapshot(
path: &Path,
source: &str,
origin: Option<&str>,
ats: &[AtRewrite],
) -> Result<(), DeckError> {
let io_err = |source| DeckError::Io {
path: path.to_path_buf(),
source,
};
let existing = std::fs::read_to_string(path).map_err(io_err)?;
let new_text = rewrite_trace_snapshot(&existing, source, origin, ats);
let tmp = path.with_extension("txt.tmp");
std::fs::write(&tmp, new_text).map_err(io_err)?;
std::fs::rename(&tmp, path).map_err(io_err)?;
Ok(())
}
fn rewrite_trace_snapshot(
text: &str,
source: &str,
origin: Option<&str>,
ats: &[AtRewrite],
) -> String {
let directive = |line: &str, key: &str| {
line.trim()
.strip_prefix('%')
.map(str::trim)
.is_some_and(|rest| rest.strip_prefix(key).is_some())
};
fn indent_of(line: &str) -> &str {
&line[..line.len() - line.trim_start().len()]
}
let mut source_replaced = false;
let mut at_i = 0;
let mut in_header = true;
let mut out: Vec<String> = Vec::new();
for line in text.lines() {
if in_header && line.starts_with('#') {
in_header = false;
}
if in_header && !source_replaced && directive(line, "source:") {
out.push(format!("% source: {source}"));
if let Some(origin) = origin {
out.push(format!("% origin: {origin}"));
}
source_replaced = true;
} else if directive(line, "at:") && at_i < ats.len() {
let indent = indent_of(line);
match &ats[at_i].origin {
Some(o) => out.push(format!("{indent}% at: {} from {o}", ats[at_i].at)),
None => out.push(format!("{indent}% at: {}", ats[at_i].at)),
}
at_i += 1;
} else {
out.push(line.to_string());
}
}
let mut joined = out.join("\n");
if text.ends_with('\n') && !joined.ends_with('\n') {
joined.push('\n');
}
joined
}
pub fn set_requires(path: &Path, deps: &[String]) -> Result<(), DeckError> {
let io_err = |source| DeckError::Io {
path: path.to_path_buf(),
source,
};
let text = std::fs::read_to_string(path).map_err(io_err)?;
let new_text = rewrite_requires(&text, deps);
let tmp = path.with_extension("txt.tmp");
std::fs::write(&tmp, new_text).map_err(io_err)?;
std::fs::rename(&tmp, path).map_err(io_err)?;
Ok(())
}
pub fn remove_cards(path: &Path, front_lines: &[usize]) -> Result<(), DeckError> {
if front_lines.is_empty() {
return Ok(());
}
let io_err = |source| DeckError::Io {
path: path.to_path_buf(),
source,
};
let text = std::fs::read_to_string(path).map_err(io_err)?;
let new_text = remove_card_blocks(&text, front_lines);
let tmp = path.with_extension("txt.tmp");
std::fs::write(&tmp, new_text).map_err(io_err)?;
std::fs::rename(&tmp, path).map_err(io_err)?;
Ok(())
}
pub fn rewrite_without_cards(
path: &Path,
original: &str,
front_lines: &[usize],
) -> Result<(), DeckError> {
let io_err = |source| DeckError::Io {
path: path.to_path_buf(),
source,
};
let new_text = remove_card_blocks(original, front_lines);
let tmp = path.with_extension("txt.tmp");
std::fs::write(&tmp, new_text).map_err(io_err)?;
std::fs::rename(&tmp, path).map_err(io_err)?;
Ok(())
}
fn remove_card_blocks(text: &str, front_lines: &[usize]) -> String {
let lines: Vec<&str> = text.lines().collect();
let is_front = |line: &str| line.starts_with('#');
let targets: std::collections::HashSet<usize> =
front_lines.iter().map(|n| n.saturating_sub(1)).collect();
let mut drop = vec![false; lines.len()];
for (i, line) in lines.iter().enumerate() {
if targets.contains(&i) && is_front(line) {
drop[i] = true;
let mut j = i + 1;
while j < lines.len() && !is_front(lines[j]) {
drop[j] = true;
j += 1;
}
}
}
let kept: Vec<&str> = lines
.iter()
.enumerate()
.filter(|(i, _)| !drop[*i])
.map(|(_, line)| *line)
.collect();
let mut result = kept.join("\n");
if text.ends_with('\n') && !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result
}
fn is_requires_line(line: &str) -> bool {
line.trim()
.strip_prefix('%')
.is_some_and(|rest| rest.trim().strip_prefix("requires:").is_some())
}
fn rewrite_requires(text: &str, deps: &[String]) -> String {
let kept: Vec<&str> = text.lines().filter(|l| !is_requires_line(l)).collect();
let mut out = String::new();
for dep in deps {
out.push_str("% requires: ");
out.push_str(dep);
out.push('\n');
}
out.push_str(&kept.join("\n"));
if text.ends_with('\n') && !out.ends_with('\n') {
out.push('\n');
}
out
}
fn insert_note_lines(text: &str, front_line: usize, notes: &[String]) -> String {
let lines: Vec<&str> = text.lines().collect();
let front_index = front_line.saturating_sub(1);
let mut last_content = front_index;
let mut i = front_index + 1;
while i < lines.len() {
if lines[i].starts_with('#') {
break;
}
if !lines[i].trim().is_empty() {
last_content = i;
}
i += 1;
}
let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
for (offset, note) in notes.iter().enumerate() {
out.insert(last_content + 1 + offset, format!("\t! {note}"));
}
let mut result = out.join("\n");
if text.ends_with('\n') {
result.push('\n');
}
result
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
fn write_deck(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
path
}
fn empty_store() -> (Store, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path().join("p.json")).unwrap();
(store, dir)
}
fn graduate(store: &mut Store, id: u64) {
store.get_or_insert(id, 0).recall = Some(crate::store::FsrsState {
state: 2, ..Default::default()
});
}
fn learning(store: &mut Store, id: u64) {
store.get_or_insert(id, 0).recall = Some(crate::store::FsrsState {
state: 1, ..Default::default()
});
}
fn retire(store: &mut Store, id: u64) {
store.get_or_insert(id, 0).recall = Some(crate::store::FsrsState {
state: 2, scheduled_days: 100_000, ..Default::default()
});
}
#[test]
fn deck_state_progresses_notstarted_started_finished() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(dir.path(), "d.txt", "# a\n\t1\n# b\n\t2\n");
let deck = Deck::load(&path).unwrap();
let (mut store, _s) = empty_store();
assert_eq!(DeckState::NotStarted, deck.state(&store));
learning(&mut store, deck.cards[0].id());
assert_eq!(DeckState::Started, deck.state(&store));
for card in &deck.cards {
graduate(&mut store, card.id());
}
assert_eq!(DeckState::Finished, deck.state(&store));
}
#[test]
fn sourced_deck_is_examdue_until_mastered() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(dir.path(), "d.txt", "% source: https://x\n# a\n\t1\n");
let deck = Deck::load(&path).unwrap();
let (mut store, _s) = empty_store();
retire(&mut store, deck.cards[0].id());
assert_eq!(DeckState::ExamDue, deck.state(&store));
store.set_deck_mastered(&deck.subject, 1);
assert_eq!(DeckState::Finished, deck.state(&store));
}
#[test]
fn a_sourced_deck_is_examdue_once_every_card_graduates() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(
dir.path(),
"d.txt",
"% source: https://x\n# a\n\t1\n# b\n\t2\n",
);
let deck = Deck::load(&path).unwrap();
let (mut store, _s) = empty_store();
graduate(&mut store, deck.cards[0].id());
learning(&mut store, deck.cards[1].id());
assert_eq!(DeckState::Started, deck.state(&store));
graduate(&mut store, deck.cards[1].id());
assert_eq!(DeckState::ExamDue, deck.state(&store));
}
#[test]
fn a_sourceless_deck_finishes_once_every_card_graduates() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(dir.path(), "d.txt", "# a\n\t1\n");
let deck = Deck::load(&path).unwrap();
let (mut store, _s) = empty_store();
graduate(&mut store, deck.cards[0].id());
assert_eq!(DeckState::Finished, deck.state(&store));
}
#[test]
fn a_deck_still_learning_a_card_is_only_started() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(dir.path(), "d.txt", "# a\n\t1\n");
let deck = Deck::load(&path).unwrap();
let (mut store, _s) = empty_store();
learning(&mut store, deck.cards[0].id());
assert_eq!(DeckState::Started, deck.state(&store));
}
#[test]
fn nongating_prerequisites_flags_a_sourceless_required_deck() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.txt", "# a\n\t1\n"); write_deck(dir.path(), "c.txt", "% source: https://x\n# c\n\t1\n"); let b_path = write_deck(
dir.path(),
"b.txt",
"% source: https://x\n% requires: a\n% requires: c\n# b\n\t1\n",
);
let b = Deck::load(&b_path).unwrap();
assert_eq!(vec!["a".to_string()], nongating_prerequisites(&b));
}
#[test]
fn nongating_prerequisites_empty_when_no_exam_or_prereq_missing() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.txt", "# a\n\t1\n");
let b = write_deck(dir.path(), "b.txt", "% requires: a\n# b\n\t1\n");
assert!(nongating_prerequisites(&Deck::load(&b).unwrap()).is_empty());
let c = write_deck(
dir.path(),
"c.txt",
"% source: https://x\n% requires: nope\n# c\n\t1\n",
);
assert!(nongating_prerequisites(&Deck::load(&c).unwrap()).is_empty());
}
#[test]
fn passing_the_exam_masters_an_undrilled_deck() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(
dir.path(),
"d.txt",
"% source: https://x\n# a\n\t1\n# b\n\t2\n",
);
let deck = Deck::load(&path).unwrap();
let (mut store, _s) = empty_store();
assert_eq!(DeckState::NotStarted, deck.state(&store));
store.set_deck_mastered(&deck.subject, 1);
assert_eq!(DeckState::Finished, deck.state(&store));
}
#[test]
fn sourceless_deck_finishes_on_drill_alone() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(dir.path(), "d.txt", "# a\n\t1\n");
let deck = Deck::load(&path).unwrap();
let (mut store, _s) = empty_store();
retire(&mut store, deck.cards[0].id());
assert_eq!(DeckState::Finished, deck.state(&store));
}
#[test]
fn dependent_stays_locked_until_sourced_prereq_mastered() {
let dir = tempfile::tempdir().unwrap();
let basics = write_deck(dir.path(), "basics.txt", "% source: https://x\n# a\n\t1\n");
let adv = write_deck(dir.path(), "advanced.txt", "% requires: basics\n# x\n\ty\n");
let advanced = Deck::load(&adv).unwrap();
let basics = Deck::load(&basics).unwrap();
let (mut store, _s) = empty_store();
let dd = Some(dir.path());
retire(&mut store, basics.cards[0].id());
assert_eq!(DeckState::ExamDue, basics.state(&store));
assert!(is_locked(&advanced, dd, &store));
store.set_deck_mastered(&basics.subject, 1);
assert!(!is_locked(&advanced, dd, &store));
}
#[test]
fn dependents_lists_requiring_decks() {
let dir = tempfile::tempdir().unwrap();
let basics = write_deck(dir.path(), "basics.txt", "# a\n\t1\n");
write_deck(dir.path(), "advanced.txt", "% requires: basics\n# x\n\ty\n");
write_deck(dir.path(), "expert.txt", "% requires: advanced\n# z\n\tw\n");
write_deck(dir.path(), "unrelated.txt", "# q\n\tr\n");
let deps = dependents(&basics, dir.path());
assert_eq!(vec!["advanced.txt"], deps);
}
#[test]
fn append_cards_appends_with_separation_and_parses() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(dir.path(), "d.txt", "# one\n\t1\n");
append_cards(&path, "# two\n% reveal: line\n\tkey point\n").unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!("# one\n\t1\n\n# two\n% reveal: line\n\tkey point\n", text);
let cards = crate::parser::parse_str("d.txt", &text).unwrap();
assert_eq!(2, cards.len());
}
#[test]
fn set_trace_checkpoints_replaces_cards_keeping_header() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(
dir.path(),
"t.txt",
"% trace: how it works\n% source: .\n\n# old question\n\told point\n\t% at: 1\n",
);
set_trace_checkpoints(
&path,
"# new q1\n\tp1\n\t% at: 2\n# new q2\n\tp2\n\t% at: 3\n",
)
.unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("% trace: how it works\n% source: .\n"));
assert!(!text.contains("old question"));
assert!(text.contains("# new q1"));
let deck = Deck::load(&path).unwrap();
assert_eq!(Some("how it works".to_string()), deck.trace);
assert_eq!(2, deck.cards.len());
}
#[test]
fn replace_after_header_appends_when_no_cards_yet() {
let text = "% trace: how it works\n% source: .\n";
let out = replace_after_header(text, "# q\n\tp\n");
assert_eq!("% trace: how it works\n% source: .\n\n# q\n\tp\n", out);
}
#[test]
fn empty_deck_is_not_started() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(dir.path(), "e.txt", "% only a comment\n");
let deck = Deck::load(&path).unwrap();
let (store, _s) = empty_store();
assert!(deck.cards.is_empty());
assert_eq!(DeckState::NotStarted, deck.state(&store));
}
#[test]
fn source_less_prerequisite_never_locks() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "basics.txt", "# a\n\t1\n");
let adv = write_deck(dir.path(), "advanced.txt", "% requires: basics\n# x\n\ty\n");
let advanced = Deck::load(&adv).unwrap();
let (store, _s) = empty_store();
let dd = Some(dir.path());
assert!(!is_locked(&advanced, dd, &store));
}
#[test]
fn lock_sees_through_a_source_less_prereq_to_a_sourced_ancestor() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.txt", "% source: https://x\n# a\n\t1\n");
write_deck(dir.path(), "b.txt", "% requires: a\n# b\n\t2\n");
let cpath = write_deck(
dir.path(),
"c.txt",
"% source: https://y\n% requires: b\n# c\n\t3\n",
);
let c = Deck::load(&cpath).unwrap();
let a = Deck::load(dir.path().join("a.txt")).unwrap();
let b = Deck::load(dir.path().join("b.txt")).unwrap();
let (mut store, _s) = empty_store();
let dd = Some(dir.path());
assert!(is_locked(&c, dd, &store));
retire(&mut store, b.cards[0].id());
assert!(is_locked(&c, dd, &store));
store.set_deck_mastered(&a.subject, 1);
assert!(!is_locked(&c, dd, &store));
}
#[test]
fn missing_prerequisite_does_not_lock() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(dir.path(), "d.txt", "% requires: nope\n# a\n\t1\n");
let deck = Deck::load(&path).unwrap();
let (store, _s) = empty_store();
assert!(!is_locked(&deck, Some(dir.path()), &store));
}
#[test]
fn a_prerequisite_cycle_resolves_locked_instead_of_hanging() {
let dir = tempfile::tempdir().unwrap();
write_deck(
dir.path(),
"a.txt",
"% source: https://x\n% requires: b\n# a\n\t1\n",
);
write_deck(
dir.path(),
"b.txt",
"% source: https://y\n% requires: a\n# b\n\t2\n",
);
let a = Deck::load(dir.path().join("a.txt")).unwrap();
let (store, _s) = empty_store();
assert!(is_locked(&a, Some(dir.path()), &store));
}
#[test]
fn resolve_dep_keeps_an_existing_extension_instead_of_forcing_txt() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("notes.md"), "x").unwrap();
let found = resolve_dep("notes.md", Some(dir.path()), None).unwrap();
assert_eq!(dir.path().join("notes.md"), found);
}
#[test]
fn load_deck_subject_is_file_name() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mydeck.txt");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "# front\nback").unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!("mydeck.txt", deck.subject);
assert_eq!(1, deck.cards.len());
assert_eq!("mydeck.txt", &*deck.cards[0].subject);
}
#[test]
fn insert_note_after_existing_card_content() {
let text = "# one\n\tback 1\n\t! old note\n\n# two\n\tback 2\n";
let notes = vec!["new a".to_string(), "new b".to_string()];
let result = insert_note_lines(text, 1, ¬es);
assert_eq!(
"# one\n\tback 1\n\t! old note\n\t! new a\n\t! new b\n\n# two\n\tback 2\n",
result
);
let cards = crate::parser::parse_str("s", &result).unwrap();
assert_eq!(Some("old note\nnew a\nnew b".to_string()), cards[0].note);
}
#[test]
fn insert_note_on_last_card_without_note() {
let text = "# one\n\tback 1\n";
let result = insert_note_lines(text, 1, &["note".to_string()]);
assert_eq!("# one\n\tback 1\n\t! note\n", result);
let cards = crate::parser::parse_str("s", &result).unwrap();
assert_eq!(Some("note".to_string()), cards[0].note);
}
#[test]
fn insert_note_targets_the_right_card() {
let text = "# one\n\tback 1\n\n# two\n\tback 2\n\n# three\n\tback 3\n";
let result = insert_note_lines(text, 4, &["mid".to_string()]);
let cards = crate::parser::parse_str("s", &result).unwrap();
assert_eq!(None, cards[0].note);
assert_eq!(Some("mid".to_string()), cards[1].note);
assert_eq!(None, cards[2].note);
}
#[test]
fn append_note_rewrites_the_file_and_card_ids_survive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "# front\n\tanswer\n").unwrap();
let before = Deck::load(&path).unwrap();
append_note(&path, 1, &["explained".to_string()]).unwrap();
let after = Deck::load(&path).unwrap();
assert_eq!(Some("explained".to_string()), after.cards[0].note);
assert_eq!(before.cards[0].id(), after.cards[0].id());
}
#[test]
fn remove_card_block_drops_front_back_and_trailing_blank() {
let text = "# one\n\tback 1\n\t! a note\n\n# two\n\tback 2\n";
assert_eq!("# two\n\tback 2\n", remove_card_blocks(text, &[1]));
assert_eq!(
"# one\n\tback 1\n\t! a note\n",
remove_card_blocks(text, &[5])
);
}
#[test]
fn remove_card_block_keeps_header_and_neighbors() {
let text = "% requires: base\n% link: https://x\n# a\n\tx\n# b\n\ty\n# c\n\tz\n";
assert_eq!(
"% requires: base\n% link: https://x\n# a\n\tx\n# c\n\tz\n",
remove_card_blocks(text, &[5])
);
}
#[test]
fn remove_card_block_handles_indented_hash_back_line() {
let text = "# q\n\t# answer with a hash\n# next\n\tb\n";
assert_eq!("# next\n\tb\n", remove_card_blocks(text, &[1]));
}
#[test]
fn remove_multiple_and_stale_line_is_ignored() {
let text = "# a\n\tx\n# b\n\ty\n# c\n\tz\n";
assert_eq!("# b\n\ty\n", remove_card_blocks(text, &[1, 2, 5]));
assert_eq!("", remove_card_blocks(text, &[1, 3, 5]));
}
#[test]
fn remove_cards_rewrites_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "# one\n\tback 1\n\n# two\n\tback 2\n").unwrap();
remove_cards(&path, &[1]).unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(1, deck.cards.len());
assert_eq!("two", deck.cards[0].front);
}
#[test]
fn settings_parsed_from_directives() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(
&path,
"% reveal: line\n% order: sequential\n% direction: bogus\n# f\n\tb\n",
)
.unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(Some(Reveal::Line), deck.settings.reveal);
assert_eq!(Some(Order::Sequential), deck.settings.order);
assert_eq!(None, deck.settings.direction);
}
#[test]
fn origin_cascades_workspace_then_deck() {
let mut deck =
DeckSettings::from_directives(&[("origin".to_string(), "/deck".to_string())]);
deck.fill_from(&DeckSettings::from_directives(&[(
"origin".to_string(),
"/ws".to_string(),
)]));
assert_eq!(Some("/deck".to_string()), deck.origin);
let mut bare = DeckSettings::default();
bare.fill_from(&DeckSettings::from_directives(&[(
"origin".to_string(),
"/ws".to_string(),
)]));
assert_eq!(Some("/ws".to_string()), bare.origin);
}
#[test]
fn reference_links_union_url_sources_excluding_files_and_dupes() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(
dir.path(),
"d.txt",
"% link: https://a.example\n\
% source: https://b.example\n\
% source: notes.md\n\
% source: https://a.example\n\
# f\n\tb\n",
);
let deck = Deck::load(&path).unwrap();
assert_eq!(
vec!["https://a.example", "https://b.example"],
deck.reference_links()
);
}
#[test]
fn strictness_directive_parses() {
let dir = tempfile::tempdir().unwrap();
let path = write_deck(dir.path(), "d.txt", "% strictness: strict\n# f\n\tb\n");
let deck = Deck::load(&path).unwrap();
assert_eq!(Some(Strictness::Strict), deck.settings.exam_strictness);
let bare = write_deck(dir.path(), "e.txt", "# f\n\tb\n");
assert_eq!(None, Deck::load(&bare).unwrap().settings.exam_strictness);
let bad = write_deck(dir.path(), "g.txt", "% strictness: harsh\n# f\n\tb\n");
assert_eq!(None, Deck::load(&bad).unwrap().settings.exam_strictness);
}
#[test]
fn reveal_directive_parses_and_stamps_cards() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("u.txt");
std::fs::write(&path, "% reveal: line\n# steps?\n\tone\n\ttwo\n").unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(Some(Reveal::Line), deck.settings.reveal);
assert_eq!(Some(Reveal::Line), deck.cards[0].reveal); }
#[test]
fn rewrite_requires_replaces_block_at_top() {
let text = "% requires: old\n# a\n\tb\n";
let out = rewrite_requires(text, &["x.txt".to_string(), "y.txt".to_string()]);
assert_eq!("% requires: x.txt\n% requires: y.txt\n# a\n\tb\n", out);
}
#[test]
fn rewrite_requires_empty_clears_them_keeping_other_comments() {
let text = "% requires: old\n% reveal: line\n# a\n\tb\n";
assert_eq!("% reveal: line\n# a\n\tb\n", rewrite_requires(text, &[]));
}
#[test]
fn set_requires_roundtrips_via_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "# front\n\tanswer\n").unwrap();
let before = Deck::load(&path).unwrap();
set_requires(&path, &["basics.txt".to_string()]).unwrap();
let after = Deck::load(&path).unwrap();
assert_eq!(vec!["basics.txt".to_string()], after.requires);
assert_eq!(before.cards[0].id(), after.cards[0].id());
set_requires(&path, &[]).unwrap();
assert!(Deck::load(&path).unwrap().requires.is_empty());
}
#[test]
fn requires_parsed_from_header() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "% requires: basics\n% requires: x.txt\n# f\n\tb\n").unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(
vec!["basics".to_string(), "x.txt".to_string()],
deck.requires
);
}
#[test]
fn card_reveal_is_card_override_else_deck_reveal() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(
&path,
"% reveal: flip\n# a\n% reveal: line\n\tx\n# b\n\ty\n",
)
.unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(Some(Reveal::Line), deck.cards[0].reveal); assert_eq!(Some(Reveal::Flip), deck.cards[1].reveal); }
#[test]
fn card_input_is_card_override_else_deck_input() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "% input: draw\n# a\n% input: type\n\tx\n# b\n\ty\n").unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(Some(Input::Type), deck.cards[0].input); assert_eq!(Some(Input::Draw), deck.cards[1].input); }
#[test]
fn cards_have_no_reveal_without_directives() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "# a\n\tx\n").unwrap();
assert_eq!(None, Deck::load(&path).unwrap().cards[0].reveal);
}
#[test]
fn direction_both_expands_to_forward_and_reverse() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "# purported\n% direction: both\n\tangeblich\n").unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(2, deck.cards.len());
assert_eq!("purported", deck.cards[0].front);
assert_eq!(vec!["angeblich"], deck.cards[0].back);
assert_eq!("angeblich", deck.cards[1].front);
assert_eq!(vec!["purported"], deck.cards[1].back);
assert_eq!(deck.cards[0].line, deck.cards[1].line); assert_ne!(deck.cards[0].id(), deck.cards[1].id());
}
#[test]
fn direction_reverse_keeps_only_the_swapped_card() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "# q\n% direction: reverse\n\ta\n").unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(1, deck.cards.len());
assert_eq!("a", deck.cards[0].front);
assert_eq!(vec!["q"], deck.cards[0].back);
}
#[test]
fn deck_depth_direction_applies_to_cards() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "% direction: both\n# a\n\tb\n").unwrap();
assert_eq!(2, Deck::load(&path).unwrap().cards.len());
}
#[test]
fn direction_does_not_apply_to_cloze() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(
&path,
"% direction: both\n# fill\n% reveal: cloze\n\tThe {{x}} thing.\n",
)
.unwrap();
assert_eq!(1, Deck::load(&path).unwrap().cards.len());
}
#[test]
fn image_resolves_against_img_dir() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(
&path,
"% img-dir: /assets/imgs\n# q\n% img: moon.png\n\tWaxing\n",
)
.unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(
Some(PathBuf::from("/assets/imgs/moon.png")),
deck.cards[0].image
);
}
#[test]
fn image_resolves_against_deck_dir_without_img_dir() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "# q\n% img: moon.png\n\tWaxing\n").unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(Some(dir.path().join("moon.png")), deck.cards[0].image);
}
#[test]
fn absolute_card_image_is_used_as_is() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(
&path,
"% img-dir: /assets\n# q\n% img: /elsewhere/moon.png\n\tWaxing\n",
)
.unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(
Some(PathBuf::from("/elsewhere/moon.png")),
deck.cards[0].image
);
}
#[test]
fn workspace_defaults_fill_unset_and_reach_cards() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "# purported\n\tangeblich\n").unwrap();
let defaults = DeckSettings {
direction: Some(Direction::Both),
reveal: Some(Reveal::Line),
..Default::default()
};
let deck = Deck::load_with_defaults(&path, &defaults).unwrap();
assert_eq!(2, deck.cards.len());
assert_eq!(Some(Reveal::Line), deck.cards[0].reveal);
}
#[test]
fn deck_directive_overrides_workspace_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "% direction: forward\n# a\n\tb\n").unwrap();
let defaults = DeckSettings {
direction: Some(Direction::Both),
..Default::default()
};
let deck = Deck::load_with_defaults(&path, &defaults).unwrap();
assert_eq!(1, deck.cards.len());
}
#[test]
fn display_name_uses_title_else_stripped_filename() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Eng-Sayings.txt");
std::fs::write(&path, "# a\n\tb\n").unwrap();
assert_eq!("Eng-Sayings", Deck::load(&path).unwrap().display_name());
std::fs::write(&path, "% title: English Sayings\n# a\n\tb\n").unwrap();
assert_eq!("English Sayings", Deck::load(&path).unwrap().display_name());
std::fs::write(&path, "% trace: how a keypress becomes a grade\n# a\n\tb\n").unwrap();
assert_eq!(
"how a keypress becomes a grade",
Deck::load(&path).unwrap().display_name()
);
}
#[test]
fn no_directives_yields_empty_settings() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "% just a comment\n# f\n\tb\n").unwrap();
let deck = Deck::load(&path).unwrap();
assert_eq!(None, deck.settings.reveal);
assert_eq!(None, deck.settings.input);
assert_eq!(None, deck.settings.order);
}
#[test]
fn duplicates_detected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(&path, "# one\nsame\n# two\nsame\n# three\nother\n").unwrap();
let deck = Deck::load(&path).unwrap();
let dups = deck.duplicates();
assert_eq!(1, dups.len());
assert_eq!("one", dups[0].0.front);
assert_eq!("two", dups[0].1.front);
}
#[test]
fn rewrite_trace_snapshot_repoints_source_origin_and_each_at() {
let text = "% trace: how X\n% source: ..\n\n# q1\n\tp\n\t% at: a.rs:90-98\n# q2\n\tp\n\t% at: b.rs:1\n";
let ats = [
AtRewrite {
at: "01.rs".into(),
origin: Some("src/a.rs:90-98".into()),
},
AtRewrite {
at: "02.rs".into(),
origin: Some("src/b.rs:1".into()),
},
];
let out = rewrite_trace_snapshot(text, "assets", Some("/crate"), &ats);
assert!(out.contains("% source: assets\n"), "{out}");
assert_eq!(1, out.matches("% source:").count()); assert!(out.contains("% origin: /crate\n"), "{out}"); assert!(out.contains("\t% at: 01.rs from src/a.rs:90-98\n"), "{out}");
assert!(out.contains("\t% at: 02.rs from src/b.rs:1\n"), "{out}");
assert!(!out.contains("! from"), "{out}"); assert!(out.contains("% trace: how X\n")); assert!(out.ends_with('\n'));
}
}