use std::{
collections::HashSet,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use crate::{
assets,
augment::AugmentCache,
deck::Deck,
parser,
store::Store,
workspace::{self, WorkspaceFiles},
};
#[derive(Debug)]
pub struct Placed {
pub path: PathBuf,
pub cards: usize,
pub parse_error: Option<String>,
}
pub fn place_deck(dir: &Path, name: &str, text: &str) -> Result<Placed> {
let stem = Path::new(name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("deck");
let stem = stem.strip_suffix(".md").unwrap_or(stem);
let file = format!("{stem}.md");
let path = dir.join(&file);
if path.exists() {
bail!("{} already exists", path.display());
}
let parsed = parser::parse_str(&file, text);
write_body(&path, text)?;
match parsed {
Ok(cards) => {
if let Err(error) = assets::initialize(&path) {
let _ = std::fs::remove_file(&path);
return Err(error.into());
}
Ok(Placed {
path,
cards: cards.len(),
parse_error: None,
})
}
Err(e) => Ok(Placed {
path,
cards: 0,
parse_error: Some(format!("{e:#}")),
}),
}
}
fn write_body(path: &Path, text: &str) -> Result<()> {
let body = if text.ends_with('\n') {
text.to_string()
} else {
format!("{text}\n")
};
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("deck");
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
let tmp = parent.join(format!(".{name}.tmp"));
crate::fsio::replace_file(&tmp, path, body.as_bytes())
.with_context(|| format!("cannot write {}", path.display()))?;
Ok(())
}
#[derive(Debug)]
pub struct ReplaceReport {
pub minted: usize,
pub wiped_cards: usize,
}
pub fn replace_deck(
dir: &Path,
name: &str,
text: &str,
store: &mut Store,
) -> Result<ReplaceReport> {
let stem = Path::new(name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("deck");
let stem = stem.strip_suffix(".md").unwrap_or(stem);
let file = format!("{stem}.md");
let path = dir.join(&file);
if let Err(e) = parser::parse(&file, text) {
let rej = dir.join(format!("{stem}.rej"));
write_body(&rej, text)?;
bail!(
"the replacement for {} does not parse; wrote it aside to {} and left the existing file untouched: {e:#}",
path.display(),
rej.display()
);
}
let mut old_card_tokens: HashSet<String> = HashSet::new();
let mut old_deck_tokens: HashSet<String> = HashSet::new();
let old_text = std::fs::read_to_string(&path).unwrap_or_default();
if let Ok(old) = parser::parse(&file, &old_text) {
for card in &old.cards {
if let Some(token) = card.token.as_deref() {
old_card_tokens.insert(token.to_string());
}
}
if let Some(token) = old.deck_token {
old_deck_tokens.insert(token);
}
}
std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
let workspace_root = workspace::root_for_member_dir(dir).unwrap_or(dir);
let workspace_files = WorkspaceFiles::new(workspace_root);
let mut trio = TrioBackup::default();
trio.back_up(&path)
.with_context(|| format!("cannot keep {} as its backup", path.display()))?;
if old_deck_tokens.len() == 1
&& let Some(deck_id) = old_deck_tokens.iter().next()
{
let progress = match crate::state::progress_document_for(store.path(), deck_id)
.context("locating the replaced deck's progress document")
{
Ok(progress) => progress,
Err(error) => {
trio.restore(&path)?;
return Err(error);
}
};
if let Err(error) = trio.back_up_copy(&progress) {
trio.restore(&path)?;
return Err(error);
}
if let Err(error) = trio.back_up(&workspace_files.augment_for(deck_id)) {
trio.restore(&path)?;
return Err(error);
}
}
if let Err(error) = write_body(&path, text) {
trio.restore(&path)?;
return Err(error);
}
let minted = match assets::initialize(&path) {
Ok(outcome) => outcome.stamp.minted_cards.len(),
Err(error) => {
let _ = std::fs::remove_file(&path);
trio.restore(&path)?;
return Err(error.into());
}
};
let old_deck_id = old_deck_tokens
.iter()
.next()
.map(String::as_str)
.unwrap_or_default();
let wiped_cards = store.wipe_deck(&old_card_tokens, old_deck_id);
if let Err(error) = store
.save()
.context("saving the store after replacing a deck")
{
trio.restore(&path)?;
return Err(error);
}
let cache_path = workspace_files.augment();
if cache_path.exists() {
let mut cache = match AugmentCache::open_for_workspace(workspace_root) {
Ok(cache) => cache,
Err(error) => {
trio.restore(&path)?;
return Err(error.into());
}
};
if cache.wipe_tokens(&old_card_tokens, &old_deck_tokens)
&& let Err(error) = cache
.save()
.with_context(|| format!("cannot save {}", cache_path.display()))
{
trio.restore(&path)?;
return Err(error);
}
}
if old_deck_tokens.len() == 1
&& let Some(deck_id) = old_deck_tokens.iter().next()
{
if let Err(error) = crate::state::retire_replaced_progress(store.path(), deck_id)
.context("retiring the replaced deck's progress")
{
trio.restore(&path)?;
return Err(error);
}
let replacement = match Deck::load(&path).context("loading the stamped replacement deck") {
Ok(replacement) => replacement,
Err(error) => {
trio.restore(&path)?;
return Err(error);
}
};
if let Err(error) = store
.rebind_replaced_deck(deck_id, &replacement)
.context("binding state to the replacement deck identity")
{
trio.restore(&path)?;
return Err(error);
}
}
Ok(ReplaceReport {
minted,
wiped_cards,
})
}
#[derive(Debug, Default)]
pub struct RemovalPreview {
pub files: Vec<PathBuf>,
pub directories: Vec<PathBuf>,
pub cards_with_progress: usize,
pub earliest_review_ms: Option<u64>,
pub dependents: Vec<String>,
}
#[derive(Debug)]
pub struct RemovalReport {
pub removed: Vec<PathBuf>,
pub dependents: Vec<String>,
}
pub fn removal_preview(deck_path: &Path, store: &Store) -> RemovalPreview {
let (files, directories, deck) = removal_set(deck_path, store);
let mut preview = RemovalPreview {
files,
directories,
dependents: crate::deck::dependents(deck_path),
..RemovalPreview::default()
};
if let Some(deck) = deck {
let mut earliest: Option<u64> = None;
for card in &deck.cards {
let Some(state) = card.id().as_deref().and_then(|id| store.get(id)) else {
continue;
};
preview.cards_with_progress += 1;
if let Some(seen) = state.presented_ms.or(state.acquired_ms) {
earliest = Some(earliest.map_or(seen, |e| e.min(seen)));
}
}
preview.earliest_review_ms = earliest;
}
preview
}
pub fn remove_deck(deck_path: &Path, store: &Store) -> Result<RemovalReport> {
let dependents = crate::deck::dependents(deck_path);
let (files, directories, _) = removal_set(deck_path, store);
let mut removed = Vec::new();
for file in files {
std::fs::remove_file(&file).with_context(|| format!("cannot remove {}", file.display()))?;
removed.push(file);
}
for dir in directories {
std::fs::remove_dir_all(&dir)
.with_context(|| format!("cannot remove {}", dir.display()))?;
removed.push(dir);
}
Ok(RemovalReport {
removed,
dependents,
})
}
fn removal_set(deck_path: &Path, store: &Store) -> (Vec<PathBuf>, Vec<PathBuf>, Option<Deck>) {
let mut files = Vec::new();
let mut directories = Vec::new();
if deck_path.exists() {
files.push(deck_path.to_path_buf());
}
let deck = Deck::load(deck_path).ok();
let mut tokens: Vec<String> = Vec::new();
for side_token in [
deck.as_ref().and_then(|d| d.deck_token.clone()),
bak_sibling(deck_path)
.and_then(|bak| Deck::load(&bak).ok())
.and_then(|d| d.deck_token),
]
.into_iter()
.flatten()
{
if !tokens.contains(&side_token) {
tokens.push(side_token);
}
}
let dir = deck_path.parent().unwrap_or_else(|| Path::new("."));
let workspace_root = workspace::root_for_member_dir(dir).unwrap_or(dir);
let workspace_files = WorkspaceFiles::new(workspace_root);
for token in &tokens {
if let Ok(progress) = crate::state::progress_document_for(store.path(), token) {
files.extend(existing_with_bak(&progress));
}
files.extend(existing_with_bak(&workspace_files.augment_for(token)));
let assets = workspace_files.assets_for(token);
if assets.is_dir() {
directories.push(assets);
}
}
if let Some(bak) = bak_sibling(deck_path) {
files.push(bak);
}
(files, directories, deck)
}
fn existing_with_bak(live: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
if live.exists() {
out.push(live.to_path_buf());
}
if let Some(bak) = bak_sibling(live) {
out.push(bak);
}
out
}
fn bak_sibling(live: &Path) -> Option<PathBuf> {
let name = live.file_name()?.to_str()?;
let bak = live.with_file_name(format!("{name}.bak"));
bak.exists().then_some(bak)
}
#[derive(Debug, Default, PartialEq)]
pub struct RestoreReport {
pub deck: bool,
pub progress: bool,
pub augment: bool,
}
pub fn restore_deck(deck_path: &Path, store_root: &Path) -> Result<RestoreReport> {
let bak_deck = deck_path.with_file_name(format!(
"{}.bak",
deck_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("deck.md")
));
if !bak_deck.exists() {
bail!("nothing to restore: {} does not exist", bak_deck.display());
}
let dir = deck_path.parent().unwrap_or_else(|| Path::new("."));
let workspace_root = workspace::root_for_member_dir(dir).unwrap_or(dir);
let workspace_files = WorkspaceFiles::new(workspace_root);
let progress_dir = store_root.join("progress");
let token_of = |path: &Path| -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let name = path.file_name()?.to_str()?;
parser::parse(name, &text).ok()?.deck_token
};
let mut tokens: Vec<String> = Vec::new();
for side in [deck_path, &bak_deck] {
if let Some(token) = token_of(side)
&& !tokens.contains(&token)
{
tokens.push(token);
}
}
swap_with_bak(deck_path)?;
let mut report = RestoreReport {
deck: true,
..RestoreReport::default()
};
for token in &tokens {
if swap_with_bak(&progress_dir.join(format!("{token}.json")))? {
report.progress = true;
}
if swap_with_bak(&workspace_files.augment_for(token))? {
report.augment = true;
}
}
Ok(report)
}
fn swap_with_bak(live: &Path) -> Result<bool> {
let name = live
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("member");
let bak = live.with_file_name(format!("{name}.bak"));
let io =
|from: &Path, to: &Path| format!("cannot swap {} with {}", from.display(), to.display());
match (live.exists(), bak.exists()) {
(true, true) => {
let tmp = live.with_file_name(format!(".{name}.swap.tmp"));
std::fs::rename(live, &tmp).with_context(|| io(live, &tmp))?;
std::fs::rename(&bak, live).with_context(|| io(&bak, live))?;
std::fs::rename(&tmp, &bak).with_context(|| io(&tmp, &bak))?;
Ok(true)
}
(true, false) => {
std::fs::rename(live, &bak).with_context(|| io(live, &bak))?;
Ok(true)
}
(false, true) => {
std::fs::rename(&bak, live).with_context(|| io(&bak, live))?;
Ok(true)
}
(false, false) => Ok(false),
}
}
#[derive(Default)]
struct TrioBackup {
renamed: Vec<(PathBuf, PathBuf)>,
}
impl TrioBackup {
fn back_up(&mut self, live: &Path) -> Result<()> {
let Some(bak) = Self::bak_path(live) else {
return Ok(());
};
std::fs::rename(live, &bak)
.with_context(|| format!("cannot keep {} as {}", live.display(), bak.display()))?;
self.renamed.push((live.to_path_buf(), bak));
Ok(())
}
fn back_up_copy(&mut self, live: &Path) -> Result<()> {
let Some(bak) = Self::bak_path(live) else {
return Ok(());
};
std::fs::copy(live, &bak)
.with_context(|| format!("cannot copy {} to {}", live.display(), bak.display()))?;
self.renamed.push((live.to_path_buf(), bak));
Ok(())
}
fn bak_path(live: &Path) -> Option<PathBuf> {
if !live.exists() {
return None;
}
let name = live.file_name().and_then(|n| n.to_str())?;
Some(live.with_file_name(format!("{name}.bak")))
}
fn restore(&mut self, subject: &Path) -> Result<()> {
while let Some((live, bak)) = self.renamed.pop() {
std::fs::rename(&bak, &live).with_context(|| {
format!(
"cannot restore {} after replacing {} failed",
live.display(),
subject.display()
)
})?;
}
Ok(())
}
}
pub fn reset_decks<'a>(
store: &mut Store,
decks: impl IntoIterator<Item = &'a Deck>,
) -> Result<usize> {
let mut n = 0;
for deck in decks {
let deck_id = deck.deck_token.as_deref().unwrap_or_default();
store.clear_deck_mastered(deck_id);
let virtual_ids: Vec<String> = store
.virtual_cards_for(deck_id)
.iter()
.map(|vc| vc.id.clone())
.collect();
for id in virtual_ids {
store.remove_virtual(&id);
store.remove(&id);
}
for card in &deck.cards {
let Some(id) = card.id() else { continue };
if store.get(&id).is_some() {
store.remove(&id);
n += 1;
}
}
}
store.save()?;
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn placing_a_valid_deck_writes_it_and_counts_cards() {
let dir = tempfile::tempdir().unwrap();
let p = place_deck(dir.path(), "rust", "## q\na\n").unwrap();
assert_eq!(dir.path().join("rust.md"), p.path);
assert_eq!(1, p.cards);
assert!(p.parse_error.is_none());
assert!(p.path.exists());
}
#[test]
fn placed_decks_land_stamped() {
let dir = tempfile::tempdir().unwrap();
let p = place_deck(dir.path(), "rust", "## q\na\n## r\nb\n").unwrap();
let deck =
crate::parser::parse("rust.md", &std::fs::read_to_string(&p.path).unwrap()).unwrap();
assert!(deck.deck_token.is_some(), "deck id minted");
assert!(
deck.cards.iter().all(|c| c.id().is_some()),
"every card stamped"
);
}
#[test]
fn failed_workspace_freezing_removes_the_uninitialized_candidate() {
let dir = tempfile::tempdir().unwrap();
let decks = dir.path().join(crate::workspace::DECKS);
std::fs::create_dir(&decks).unwrap();
std::fs::write(dir.path().join(crate::workspace::MANIFEST), "").unwrap();
let error =
place_deck(&decks, "facts", "---\nsource: missing.md\n---\n## q\na\n").unwrap_err();
assert!(format!("{error:#}").contains("missing.md"));
assert!(!decks.join("facts.md").exists());
assert!(!dir.path().join(crate::assets::ROOT).exists());
}
#[test]
fn a_parse_problem_still_writes_the_deck_and_reports_it() {
let dir = tempfile::tempdir().unwrap();
let p = place_deck(dir.path(), "broken.md", "## q with no answer\n").unwrap();
assert!(p.path.exists());
assert!(p.parse_error.is_some());
}
#[test]
fn a_name_collision_errors_without_touching_the_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("rust.md"), "original").unwrap();
let err = place_deck(dir.path(), "rust", "## q\na\n").unwrap_err();
assert!(format!("{err:#}").contains("already exists"), "{err:#}");
assert_eq!(
"original",
std::fs::read_to_string(dir.path().join("rust.md")).unwrap()
);
}
#[test]
fn an_uploaded_name_cannot_traverse_out_of_the_dir() {
let dir = tempfile::tempdir().unwrap();
let p = place_deck(dir.path(), "../../evil", "## q\na\n").unwrap();
assert!(p.path.starts_with(dir.path()), "{}", p.path.display());
}
#[test]
fn resetting_a_deck_clears_only_that_decks_progress() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("a.md"),
"---\nformat-version: 1\nid: \"deck-da\"\n---\n## qa <!-- id: card-qa -->\nans-a\n",
)
.unwrap();
std::fs::write(
dir.path().join("b.md"),
"---\nformat-version: 1\nid: \"deck-db\"\n---\n## qb <!-- id: card-qb -->\nans-b\n",
)
.unwrap();
let deck_a = Deck::load(dir.path().join("a.md")).unwrap();
let deck_b = Deck::load(dir.path().join("b.md")).unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.get_or_insert(&deck_a.cards[0].id().unwrap(), 0);
store.get_or_insert(&deck_b.cards[0].id().unwrap(), 0);
store.set_deck_mastered(deck_a.deck_token.as_deref().unwrap(), 0);
let n = reset_decks(&mut store, [&deck_a]).unwrap();
assert_eq!(1, n);
assert!(
store.get(&deck_a.cards[0].id().unwrap()).is_none(),
"a's schedule wiped"
);
assert!(
store.get(&deck_b.cards[0].id().unwrap()).is_some(),
"b's schedule intact"
);
assert!(!store.deck_mastered(deck_a.deck_token.as_deref().unwrap()));
}
fn virtual_card(deck_id: &str, back: &str) -> crate::store::VirtualCard {
let slug: String = back.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
let text = format!(
"## front <!-- id: card-v{} -->\n{back}\n",
slug.to_ascii_lowercase()
);
let id = crate::parser::parse_str(deck_id, &text).unwrap()[0]
.id()
.unwrap();
crate::store::VirtualCard {
id,
kind: crate::store::VirtualKind::Remediation,
deck: deck_id.to_string(),
text,
created_ms: 0,
}
}
fn write_deck(dir: &Path, name: &str, deck_token: &str, card_token: &str) {
std::fs::write(
dir.join(name),
format!(
"---\nformat-version: 1\nid: \"deck-{deck_token}\"\n---\n## q <!-- id: card-{card_token} -->\nans\n"
),
)
.unwrap();
}
#[test]
fn unparseable_regeneration_aborts_before_touching_the_old_file_and_writes_a_rej() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
let orig = std::fs::read_to_string(dir.path().join("a.md")).unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
store.get_or_insert("c1", 0);
store.save().unwrap();
let err =
replace_deck(dir.path(), "a", "## broken with no answer\n", &mut store).unwrap_err();
assert!(format!("{err:#}").contains("does not parse"), "{err:#}");
assert_eq!(
orig,
std::fs::read_to_string(dir.path().join("a.md")).unwrap()
);
assert!(!dir.path().join("a.md.bak").exists());
let rej = std::fs::read_to_string(dir.path().join("a.rej")).unwrap();
assert!(rej.contains("## broken with no answer"), "{rej}");
assert!(store.get("c1").is_some());
}
#[test]
fn the_replaced_deck_is_kept_as_a_bak_and_baks_are_not_decks() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
let orig = std::fs::read_to_string(dir.path().join("a.md")).unwrap();
let mut store = crate::state::open_store(&dir.path().join("a.md"), dir.path()).unwrap();
replace_deck(dir.path(), "a", "## new q\nnew ans\n", &mut store).unwrap();
assert_eq!(
orig,
std::fs::read_to_string(dir.path().join("a.md.bak")).unwrap()
);
let now = std::fs::read_to_string(dir.path().join("a.md")).unwrap();
assert!(now.contains("new q"), "{now}");
let decks = crate::workspace::deck_files(dir.path());
assert_eq!(1, decks.len(), "{decks:?}");
assert!(decks[0].ends_with("a.md"));
}
#[test]
fn a_replace_backs_up_the_full_trio_before_wiping() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
let orig_deck = std::fs::read_to_string(dir.path().join("a.md")).unwrap();
let mut store = crate::state::open_store(&dir.path().join("a.md"), dir.path()).unwrap();
store.get_or_insert("card-c1", 0);
store.ensure_records_raw("card-c1", &[]);
store.save().unwrap();
let progress = dir.path().join("progress/deck-da1.json");
let orig_progress = std::fs::read(&progress).unwrap();
let deck = Deck::load(dir.path().join("a.md")).unwrap();
let mut cache = AugmentCache::open_for_decks(dir.path(), &[deck]).unwrap();
cache.set_distractors("card-c1", vec!["x".into()], 1);
cache.save().unwrap();
let augment = WorkspaceFiles::new(dir.path()).augment_for("deck-da1");
let orig_augment = std::fs::read(&augment).unwrap();
replace_deck(dir.path(), "a", "## new q\nnew ans\n", &mut store).unwrap();
assert_eq!(
orig_deck,
std::fs::read_to_string(dir.path().join("a.md.bak")).unwrap(),
"the deck backup holds the pre-replace text"
);
assert_eq!(
orig_progress,
std::fs::read(dir.path().join("progress/deck-da1.json.bak")).unwrap(),
"the progress backup holds the pre-wipe document"
);
assert_eq!(
orig_augment,
std::fs::read(dir.path().join("augment/deck-da1.json.bak")).unwrap(),
"the augment backup holds the pre-wipe sidecar"
);
assert!(
!progress.exists(),
"the emptied live progress document is retired, not kept beside its backup"
);
}
#[test]
fn a_replace_that_fails_after_its_renames_restores_the_whole_trio() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("ws");
let members = ws.join("decks");
std::fs::create_dir_all(&members).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"W\"\n").unwrap();
write_deck(&members, "a.md", "da1", "c1");
let orig_deck = std::fs::read_to_string(members.join("a.md")).unwrap();
let mut store = crate::state::open_store(&members.join("a.md"), dir.path()).unwrap();
store.get_or_insert("card-c1", 0);
store.save().unwrap();
let progress = store.path().to_path_buf();
let orig_progress = std::fs::read(&progress).unwrap();
let deck = Deck::load(members.join("a.md")).unwrap();
let mut cache = AugmentCache::open_for_decks(&ws, &[deck]).unwrap();
cache.set_distractors("card-c1", vec!["x".into()], 1);
cache.save().unwrap();
let augment = WorkspaceFiles::new(&ws).augment_for("deck-da1");
let orig_augment = std::fs::read(&augment).unwrap();
let err = replace_deck(
&members,
"a",
"---\nsource: missing.md\n---\n## new q\nnew ans\n",
&mut store,
)
.unwrap_err();
assert!(format!("{err:#}").contains("missing"), "{err:#}");
assert_eq!(
orig_deck,
std::fs::read_to_string(members.join("a.md")).unwrap(),
"the live deck is restored"
);
assert_eq!(
orig_progress,
std::fs::read(&progress).unwrap(),
"the live progress document is restored"
);
assert_eq!(
orig_augment,
std::fs::read(&augment).unwrap(),
"the live augment sidecar is restored"
);
for leftover in [
members.join("a.md.bak"),
progress.with_extension("json.bak"),
augment.with_extension("json.bak"),
] {
assert!(
!leftover.exists(),
"no {} lingers after a restored failure",
leftover.display()
);
}
}
fn trio_fixture(dir: &Path) -> (Vec<u8>, Vec<u8>, String) {
write_deck(dir, "a.md", "da1", "c1");
let mut store = crate::state::open_store(&dir.join("a.md"), dir).unwrap();
store.get_or_insert("card-c1", 0);
store.ensure_records_raw("card-c1", &[]);
store.save().unwrap();
let deck = Deck::load(dir.join("a.md")).unwrap();
let mut cache = AugmentCache::open_for_decks(dir, &[deck]).unwrap();
cache.set_distractors("card-c1", vec!["x".into()], 1);
cache.save().unwrap();
let orig_deck = std::fs::read_to_string(dir.join("a.md")).unwrap();
let orig_progress = std::fs::read(dir.join("progress/deck-da1.json")).unwrap();
let orig_augment = std::fs::read(dir.join("augment/deck-da1.json")).unwrap();
replace_deck(dir, "a", "## new q\nnew ans\n", &mut store).unwrap();
(orig_progress, orig_augment, orig_deck)
}
#[test]
fn restore_swaps_the_replacement_away_and_brings_history_and_augment_back() {
let dir = tempfile::tempdir().unwrap();
let (orig_progress, orig_augment, orig_deck) = trio_fixture(dir.path());
let replaced_deck = std::fs::read_to_string(dir.path().join("a.md")).unwrap();
let report = restore_deck(&dir.path().join("a.md"), dir.path()).unwrap();
assert_eq!(
RestoreReport {
deck: true,
progress: true,
augment: true,
},
report
);
assert_eq!(
orig_deck,
std::fs::read_to_string(dir.path().join("a.md")).unwrap(),
"the original deck text is live again"
);
assert_eq!(
orig_progress,
std::fs::read(dir.path().join("progress/deck-da1.json")).unwrap(),
"the review history is live again"
);
assert_eq!(
orig_augment,
std::fs::read(dir.path().join("augment/deck-da1.json")).unwrap(),
"the augmentations are live again"
);
assert_eq!(
replaced_deck,
std::fs::read_to_string(dir.path().join("a.md.bak")).unwrap(),
"the restored-away replacement is preserved as the new backup"
);
}
#[test]
fn restore_twice_is_a_byte_identical_round_trip() {
let dir = tempfile::tempdir().unwrap();
trio_fixture(dir.path());
let snapshot = |root: &Path| -> Vec<(String, Vec<u8>)> {
let mut all = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).unwrap() {
let p = e.unwrap().path();
if p.is_dir() {
stack.push(p);
} else {
let rel = p.strip_prefix(root).unwrap().display().to_string();
all.push((rel, std::fs::read(&p).unwrap()));
}
}
}
all.sort();
all
};
let before = snapshot(dir.path());
restore_deck(&dir.path().join("a.md"), dir.path()).unwrap();
restore_deck(&dir.path().join("a.md"), dir.path()).unwrap();
assert_eq!(
before,
snapshot(dir.path()),
"a double restore must reproduce every file byte for byte"
);
}
#[test]
fn a_partial_trio_swaps_the_deck_and_reports_the_absent_members() {
let dir = tempfile::tempdir().unwrap();
let (_, _, orig_deck) = trio_fixture(dir.path());
std::fs::remove_file(dir.path().join("progress/deck-da1.json.bak")).unwrap();
std::fs::remove_file(dir.path().join("augment/deck-da1.json.bak")).unwrap();
let report = restore_deck(&dir.path().join("a.md"), dir.path()).unwrap();
assert_eq!(
RestoreReport {
deck: true,
progress: false,
augment: false,
},
report
);
assert_eq!(
orig_deck,
std::fs::read_to_string(dir.path().join("a.md")).unwrap()
);
}
#[test]
fn removing_a_deck_deletes_file_progress_augment_and_every_bak() {
let dir = tempfile::tempdir().unwrap();
trio_fixture(dir.path());
let mut store = crate::state::open_store(&dir.path().join("a.md"), dir.path()).unwrap();
let live = Deck::load(dir.path().join("a.md")).unwrap();
store.get_or_insert(&live.cards[0].id().unwrap(), 0);
store.save().unwrap();
let report = remove_deck(&dir.path().join("a.md"), &store).unwrap();
assert!(report.removed.len() >= 4, "{:?}", report.removed);
assert!(!dir.path().join("a.md").exists(), "deck gone");
assert!(!dir.path().join("a.md.bak").exists(), "deck backup gone");
let leftovers: Vec<_> = ["progress", "augment"]
.iter()
.flat_map(|sub| {
std::fs::read_dir(dir.path().join(sub))
.into_iter()
.flatten()
})
.map(|e| e.unwrap().path())
.filter(|p| {
let n = p.file_name().unwrap().to_string_lossy().to_string();
n.contains("da1")
|| n.ends_with(".bak")
|| n.contains(live.deck_token.as_deref().unwrap())
})
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
#[test]
fn removing_a_workspace_member_deletes_its_frozen_assets_directory() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("ws");
let members = ws.join("decks");
std::fs::create_dir_all(&members).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"W\"\n").unwrap();
write_deck(&members, "a.md", "da1", "c1");
let assets = ws.join("assets/deck-da1");
std::fs::create_dir_all(&assets).unwrap();
std::fs::write(assets.join("img.png"), b"png").unwrap();
let store = crate::state::open_store(&members.join("a.md"), dir.path()).unwrap();
let preview = removal_preview(&members.join("a.md"), &store);
assert!(
preview.directories.contains(&assets),
"the preview names the assets dir: {preview:?}"
);
remove_deck(&members.join("a.md"), &store).unwrap();
assert!(!members.join("a.md").exists());
assert!(!assets.exists(), "the frozen assets directory is gone");
}
#[test]
fn the_removal_preview_names_the_stakes_and_the_dependents() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
std::fs::write(
dir.path().join("b.md"),
"---\nformat-version: 1\nid: \"deck-db1\"\nrequires: [\"a.md\"]\n---\n## qb <!-- id: card-cb1 -->\nb\n",
)
.unwrap();
let mut store = crate::state::open_store(&dir.path().join("a.md"), dir.path()).unwrap();
store.get_or_insert("card-c1", 4_200);
store.save().unwrap();
let preview = removal_preview(&dir.path().join("a.md"), &store);
assert_eq!(1, preview.cards_with_progress);
assert!(
preview.files.contains(&dir.path().join("a.md")),
"{preview:?}"
);
assert!(
preview
.files
.iter()
.any(|p| p.ends_with("progress/deck-da1.json")),
"{preview:?}"
);
assert_eq!(vec!["b.md".to_string()], preview.dependents);
}
#[test]
fn a_removal_failure_mid_set_is_loud_and_leaves_a_detectable_state() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
let mut store = crate::state::open_store(&dir.path().join("a.md"), dir.path()).unwrap();
store.get_or_insert("card-c1", 0);
store.save().unwrap();
let progress = dir.path().join("progress/deck-da1.json");
std::fs::remove_file(&progress).unwrap();
std::fs::create_dir_all(progress.join("x")).unwrap();
let err = remove_deck(&dir.path().join("a.md"), &store).unwrap_err();
assert!(
format!("{err:#}").contains("deck-da1.json"),
"the error names the member that failed: {err:#}"
);
assert!(
!dir.path().join("a.md").exists(),
"the deck-first ordering already removed the deck"
);
assert!(
progress.exists(),
"the failed member is still there for doctor to flag"
);
}
#[test]
fn restore_without_any_backup_is_a_clean_error_naming_the_missing_file() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
let err = restore_deck(&dir.path().join("a.md"), dir.path()).unwrap_err();
assert!(
format!("{err:#}").contains("a.md.bak"),
"the error names what was looked for: {err:#}"
);
assert!(dir.path().join("a.md").exists(), "nothing was touched");
}
#[test]
fn replacing_a_deck_wipes_its_progress_augment_entries_and_parented_virtuals() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
write_deck(dir.path(), "b.md", "db1", "cb1");
let paths = [dir.path().join("a.md"), dir.path().join("b.md")];
let decks = paths
.iter()
.map(Deck::load)
.collect::<Result<Vec<_>, _>>()
.unwrap();
let mut store = crate::state::open_stores(&paths, dir.path()).unwrap();
store.get_or_insert("card-c1", 0);
store.set_deck_mastered("deck-da1", 1);
store.ensure_records_raw("card-c1", &[]);
store.insert_virtual(crate::store::VirtualCard {
id: "card-va1".into(),
kind: crate::store::VirtualKind::Remediation,
deck: "deck-da1".into(),
text: "## v <!-- id: card-va1 -->\nvans\n".into(),
created_ms: 0,
});
store.get_or_insert("card-va1", 0);
store.get_or_insert("card-cb1", 0);
store.set_deck_mastered("deck-db1", 1);
store.save().unwrap();
let cache_path = WorkspaceFiles::new(dir.path()).augment();
let mut cache = AugmentCache::open_for_decks(dir.path(), &decks).unwrap();
cache.set_distractors("card-c1", vec!["x".into()], 1);
cache.add_topology(crate::augment::Topology {
name: "auto".into(),
deck_token: "deck-da1".into(),
..Default::default()
});
cache.set_distractors("card-cb1", vec!["y".into()], 1);
cache.add_topology(crate::augment::Topology {
name: "auto".into(),
deck_token: "deck-db1".into(),
..Default::default()
});
cache.save().unwrap();
let report = replace_deck(dir.path(), "a", "## new q\nnew ans\n", &mut store).unwrap();
assert_eq!(1, report.wiped_cards);
assert!(store.get("card-c1").is_none());
assert!(!store.deck_mastered("deck-da1"));
assert!(store.records("card-c1").is_none());
assert!(store.get_virtual("card-va1").is_none());
assert!(store.get("card-va1").is_none());
assert!(store.get("card-cb1").is_some());
assert!(store.deck_mastered("deck-db1"));
let cache = AugmentCache::open(&cache_path);
assert!(cache.distractors("card-c1", 1).is_none());
assert!(!cache.has_topology_for(&once("deck-da1")));
assert!(cache.distractors("card-cb1", 1).is_some());
assert!(cache.has_topology_for(&once("deck-db1")));
}
#[test]
fn a_replaced_deck_leaves_no_orphaned_store_keys() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
let mut store = crate::state::open_store(&dir.path().join("a.md"), dir.path()).unwrap();
store.get_or_insert("card-c1", 0);
store.set_deck_mastered("deck-da1", 1);
store.ensure_records_raw("card-c1", &[]);
store.save().unwrap();
replace_deck(dir.path(), "a", "## new q\nnew ans\n", &mut store).unwrap();
let deck = Deck::load(dir.path().join("a.md")).unwrap();
let known_ids: HashSet<String> = deck.cards.iter().filter_map(|c| c.id()).collect();
let known_deck_id = deck.deck_token.clone().unwrap();
let orphans = store.orphans(&known_ids, &once(&known_deck_id));
assert!(orphans.is_empty(), "{orphans:?}");
}
#[test]
fn every_replacement_mints_fresh_tokens() {
let dir = tempfile::tempdir().unwrap();
place_deck(dir.path(), "a", "## old q\nold ans\n## old r\nold b\n").unwrap();
let old_text = std::fs::read_to_string(dir.path().join("a.md")).unwrap();
let old = crate::parser::parse("a.md", &old_text).unwrap();
let old_tokens: Vec<String> = old
.cards
.iter()
.filter_map(|c| c.token.as_deref().map(str::to_string))
.chain(old.deck_token.clone())
.collect();
assert!(old_tokens.len() >= 3, "{old_tokens:?}");
let mut store = crate::state::open_store(&dir.path().join("a.md"), dir.path()).unwrap();
replace_deck(dir.path(), "a", "## new q\nnew ans\n", &mut store).unwrap();
let now = std::fs::read_to_string(dir.path().join("a.md")).unwrap();
for tok in &old_tokens {
assert!(!now.contains(tok.as_str()), "old token {tok} reappeared");
}
let bak = std::fs::read_to_string(dir.path().join("a.md.bak")).unwrap();
assert!(old_tokens.iter().all(|t| bak.contains(t.as_str())));
}
#[test]
fn replacing_one_per_deck_document_retires_its_old_identity_without_touching_its_neighbor() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
write_deck(dir.path(), "b.md", "db1", "c2");
let paths = [dir.path().join("a.md"), dir.path().join("b.md")];
let mut aggregate = crate::state::open_stores(&paths, dir.path()).unwrap();
aggregate.get_or_insert("card-c1", 0);
aggregate.get_or_insert("card-c2", 0);
aggregate.save().unwrap();
let mut augmentation = AugmentCache::open_for_decks(
dir.path(),
&paths
.iter()
.map(Deck::load)
.collect::<Result<Vec<_>, _>>()
.unwrap(),
)
.unwrap();
augmentation.set_note("card-c1", "first".to_string(), 1);
augmentation.set_note("card-c2", "second".to_string(), 1);
augmentation.save().unwrap();
replace_deck(dir.path(), "a", "## new q\nnew ans\n", &mut aggregate).unwrap();
assert!(!dir.path().join("progress/deck-da1.json").exists());
assert!(!dir.path().join("augment/deck-da1.json").exists());
assert!(dir.path().join("progress/deck-db1.json").exists());
assert!(dir.path().join("augment/deck-db1.json").exists());
let untouched = Store::open_deck(
dir.path().join("progress/deck-db1.json"),
"deck-db1",
"b.md",
)
.unwrap();
assert!(untouched.get("card-c2").is_some());
let untouched_augmentation =
AugmentCache::open_deck(dir.path().join("augment/deck-db1.json"), "deck-db1").unwrap();
assert_eq!(Some("second"), untouched_augmentation.note("card-c2", 1));
}
#[test]
fn a_second_replace_overwrites_the_prior_bak() {
let dir = tempfile::tempdir().unwrap();
write_deck(dir.path(), "a.md", "da1", "c1");
let mut store = crate::state::open_store(&dir.path().join("a.md"), dir.path()).unwrap();
replace_deck(dir.path(), "a", "## first q\nfirst ans\n", &mut store).unwrap();
let first = std::fs::read_to_string(dir.path().join("a.md")).unwrap();
replace_deck(dir.path(), "a", "## second q\nsecond ans\n", &mut store).unwrap();
let baks: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".bak"))
.collect();
assert_eq!(1, baks.len(), "{baks:?}");
assert_eq!(
first,
std::fs::read_to_string(dir.path().join("a.md.bak")).unwrap()
);
}
const MARKER_FIXTURE: &str = "---\nsource: notes.md\nrequires: basics\n---\n# The Title\nintro prose\n\n## First question \nextra front line\n\n---\nthe answer\n\\--- escaped divider\n> a note\n```\nfenced\n## not a card\n```\ntail prose\n\n## Fill in the blanks\nthe \\blank{alpha} and \\blank{beta} here\n> cloze note\n";
fn all_tokens(subject: &str, text: &str) -> Vec<String> {
let deck = crate::parser::parse(subject, text).unwrap();
let mut toks = Vec::new();
let mut last_line = None;
for card in &deck.cards {
if last_line != Some(card.line) {
if let Some(t) = card.token.as_deref() {
toks.push(t.to_string());
}
last_line = Some(card.line);
}
}
toks.extend(deck.deck_token);
toks
}
fn assert_no_duplicate_tokens(subject: &str, text: &str) {
let toks = all_tokens(subject, text);
let uniq: HashSet<&String> = toks.iter().collect();
assert_eq!(
toks.len(),
uniq.len(),
"duplicate token in {subject}: {toks:?}"
);
}
#[test]
fn every_writer_preserves_tokens_and_text_and_never_duplicates() {
{
let dir = tempfile::tempdir().unwrap();
place_deck(dir.path(), "d", MARKER_FIXTURE).unwrap();
let text = std::fs::read_to_string(dir.path().join("d.md")).unwrap();
let deck = crate::parser::parse("d.md", &text).unwrap();
assert!(deck.cards.iter().all(|c| c.token.is_some()), "all stamped");
assert!(text.contains("First question"), "front text kept");
assert!(
text.contains("alpha") && text.contains("beta"),
"cloze kept"
);
assert_no_duplicate_tokens("d.md", &text);
}
{
let dir = tempfile::tempdir().unwrap();
place_deck(dir.path(), "d", "## x\ny\n").unwrap();
let mut store = crate::state::open_store(&dir.path().join("d.md"), dir.path()).unwrap();
replace_deck(dir.path(), "d", MARKER_FIXTURE, &mut store).unwrap();
let text = std::fs::read_to_string(dir.path().join("d.md")).unwrap();
let deck = crate::parser::parse("d.md", &text).unwrap();
assert!(deck.cards.iter().all(|c| c.token.is_some()), "all stamped");
assert!(text.contains("First question"));
assert_no_duplicate_tokens("d.md", &text);
}
{
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.md");
std::fs::write(&path, MARKER_FIXTURE).unwrap();
crate::stamp::stamp_deck(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let deck = crate::parser::parse("d.md", &text).unwrap();
assert!(deck.cards.iter().all(|c| c.token.is_some()));
assert!(text.contains("First question"));
assert_no_duplicate_tokens("d.md", &text);
}
{
let dir = tempfile::tempdir().unwrap();
let placed = place_deck(dir.path(), "d", "## base\nb\n").unwrap();
let added = "card-aaaaaaaaaaaaaaaaaaaaaaaaap";
crate::deck::append_cards(
&placed.path,
&format!("## added <!-- id: {added} -->\nans\n"),
)
.unwrap();
let text = std::fs::read_to_string(&placed.path).unwrap();
assert!(text.contains(added), "appended token preserved");
assert_no_duplicate_tokens("d.md", &text);
}
{
let dir = tempfile::tempdir().unwrap();
let placed = place_deck(dir.path(), "d", "## base\nb\n").unwrap();
let mut store = crate::state::open_store(&placed.path, dir.path()).unwrap();
let vid = "card-pvzzzzzzzzzzzzzzzzzzzzzzzzz";
store.insert_virtual(crate::store::VirtualCard {
id: vid.into(),
kind: crate::store::VirtualKind::Tutor,
deck: "d.md".into(),
text: format!("## promoted <!-- id: {vid} -->\npans\n"),
created_ms: 0,
});
store.get_or_insert(vid, 0);
store.save().unwrap();
crate::store::promote_virtual(&mut store, vid, &placed.path).unwrap();
let text = std::fs::read_to_string(&placed.path).unwrap();
assert!(text.contains(vid), "promoted token preserved");
assert_no_duplicate_tokens("d.md", &text);
}
}
fn once(s: &str) -> HashSet<String> {
std::iter::once(s.to_string()).collect()
}
#[test]
fn a_trace_rebuild_routes_through_replace_and_wipes_the_old_checkpoints() {
let dir = tempfile::tempdir().unwrap();
let existing = "---\nformat-version: 1\nid: \"deck-da1\"\ntrace: how x becomes y\nsource: notes.md\n---\n## old cp <!-- id: card-c1 -->\nold\n";
let path = dir.path().join("t.md");
std::fs::write(&path, existing).unwrap();
let mut store = crate::state::open_store(&path, dir.path()).unwrap();
store.get_or_insert("card-c1", 0);
store.save().unwrap();
let new_text = crate::deck::trace_checkpoint_text(
&dir.path().join("t.md"),
existing,
"## new cp\nnew\n",
)
.unwrap();
assert!(new_text.contains("trace: how x becomes y"));
assert!(new_text.contains("source: notes.md"));
replace_deck(dir.path(), "t", &new_text, &mut store).unwrap();
assert!(store.get("card-c1").is_none());
let now = std::fs::read_to_string(dir.path().join("t.md")).unwrap();
assert!(now.contains("new cp"));
let rebuilt = crate::parser::parse("t.md", &now).unwrap();
assert_eq!(1, rebuilt.cards.len());
assert!(
rebuilt.cards[0].token.is_some(),
"the rebuilt checkpoint is stamped"
);
assert_ne!(
Some("card-c1"),
rebuilt.cards[0].token.as_deref(),
"old token must not survive as the rebuilt card id"
);
}
#[test]
fn resetting_a_deck_drops_its_virtual_cards_but_keeps_anothers() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("a.md"),
"---\nformat-version: 1\nid: \"deck-da\"\n---\n## qa <!-- id: card-qa -->\nans-a\n",
)
.unwrap();
let deck_a = Deck::load(dir.path().join("a.md")).unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let vc_a = virtual_card("deck-da", "vc-a");
let vc_other = virtual_card("deck-other", "vc-other");
let (id_a, id_other) = (vc_a.id.clone(), vc_other.id.clone());
store.insert_virtual(vc_a);
store.insert_virtual(vc_other);
store.get_or_insert(&id_a, 0);
store.get_or_insert(&id_other, 0);
let n = reset_decks(&mut store, [&deck_a]).unwrap();
assert_eq!(0, n, "no authored cards had progress");
assert!(
store.get_virtual(&id_a).is_none(),
"a's virtual card dropped"
);
assert!(store.get(&id_a).is_none(), "a's virtual schedule dropped");
assert!(
store.get_virtual(&id_other).is_some(),
"another deck's virtual card survives"
);
}
}