use std::{
collections::HashSet,
path::{Path, PathBuf},
};
use crate::{
card::Card,
config::ReviewConfig,
deck::{self, Deck, DeckState},
depth::Depth,
parser,
recent::RecentDecks,
session,
store::{self, Store},
title, workspace,
};
struct Candidate {
path: PathBuf,
name: String,
last_used_ms: Option<u64>,
is_workspace: bool,
}
fn dir_candidates(decks_dir: &Path) -> Vec<Candidate> {
let mut cands: Vec<Candidate> = match std::fs::read_dir(decks_dir) {
Ok(read_dir) => read_dir
.filter_map(|r| r.ok().map(|d| d.path()))
.filter_map(|path| {
if path.is_file() && path.extension().is_some_and(|e| e == "txt") {
Some((path, false))
} else if workspace::has_decks(&path) {
Some((path, true))
} else {
None
}
})
.map(|(path, is_workspace)| Candidate {
name: file_name(&path),
path,
last_used_ms: None,
is_workspace,
})
.collect(),
Err(_) => Vec::new(),
};
cands.sort_by(|a, b| a.name.cmp(&b.name));
cands
}
fn build_candidates(decks_dir: &Path, recent: &RecentDecks) -> Vec<Candidate> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for entry in recent.entries() {
let is_workspace = workspace::has_decks(&entry.path);
if entry.path.is_file() || is_workspace {
out.push(Candidate {
name: file_name(&entry.path),
path: entry.path.clone(),
last_used_ms: Some(entry.last_used_ms),
is_workspace,
});
seen.insert(entry.path.clone());
}
}
for candidate in dir_candidates(decks_dir) {
if !seen.contains(&candidate.path) {
out.push(candidate);
}
}
out
}
pub struct DeckStatus {
pub state: DeckState,
pub badge: String,
pub locked: bool,
pub reviewable: bool,
pub reviewable_recognize: bool,
pub reviewable_recall: bool,
pub reviewable_reconstruct: bool,
pub mastered: bool,
pub is_trace: bool,
pub examable: bool,
pub has_exam: bool,
pub badge_depth: Option<Depth>,
pub badge_dotted: bool,
pub new_cards: bool,
}
fn badge_depth_for(subject: &str, cards: &[Card], store: &Store) -> (Option<Depth>, bool) {
const DEPTHS: [Depth; 3] = [Depth::Reconstruct, Depth::Recall, Depth::Recognize];
if let Some(depth) = DEPTHS
.into_iter()
.find(|&depth| store::badge_solid(cards, store, depth))
{
return (Some(depth), false);
}
let earned = DEPTHS
.into_iter()
.find(|&depth| store.badge_earned(subject, depth).is_some());
let dotted = earned.is_some();
(earned, dotted)
}
pub fn deck_status(
deck: &Deck,
store: &Store,
decks_dir: Option<&Path>,
enforce_locks: bool,
review: ReviewConfig,
) -> DeckStatus {
let state = deck.state(store);
let total = deck.cards.len();
let retired = deck
.cards
.iter()
.filter(|card| session::is_retired(card, store, review.retire_after_days))
.count();
let graduated = deck
.cards
.iter()
.filter(|card| session::has_graduated(card, store))
.count();
let mastered = matches!(state, DeckState::Finished) && store.deck_mastered(&deck.subject);
let badge = match state {
DeckState::Finished if mastered => {
let mut s = "mastered 🎉".to_string();
if let Some(at) = store.deck_mastered_at(&deck.subject) {
let ago = crate::time::humanize_ms(session::now_ms().saturating_sub(at));
s.push_str(&format!(" · {ago} ago"));
}
let to_drill = total - retired;
if to_drill > 0 {
s.push_str(&format!(" · {to_drill} to drill"));
}
s
}
DeckState::Finished => "done ✓".to_string(),
DeckState::ExamDue => "exam due".to_string(),
DeckState::NotStarted => "new".to_string(),
DeckState::Started => format!("{graduated}/{total}"),
};
let actually_locked = deck::is_locked(deck, decks_dir, store);
let locked = enforce_locks && actually_locked;
let has_exam = deck.has_exam();
let examable = has_exam && !actually_locked;
let scheduler = crate::scheduler::Fsrs::new(review.retention);
let now = session::now_ms();
let reviewable_recognize = session::has_reviewable(
&deck.cards,
store,
&scheduler,
Depth::Recognize,
now,
review.retire_after_days,
);
let reviewable_recall = session::has_reviewable(
&deck.cards,
store,
&scheduler,
Depth::Recall,
now,
review.retire_after_days,
) || session::has_reviewable_virtual(
store,
&deck.subject,
&scheduler,
now,
review.retire_after_days,
);
let reviewable_reconstruct = session::has_reviewable(
&deck.cards,
store,
&scheduler,
Depth::Reconstruct,
now,
review.retire_after_days,
);
let reviewable = deck.is_trace()
|| (matches!(state, DeckState::ExamDue) && examable)
|| reviewable_recognize
|| reviewable_recall
|| reviewable_reconstruct;
let (badge_depth, badge_dotted) = badge_depth_for(&deck.subject, &deck.cards, store);
let new_cards = deck.cards.iter().any(|card| store.get(card.id()).is_none());
DeckStatus {
state,
badge,
locked,
reviewable,
reviewable_recognize,
reviewable_recall,
reviewable_reconstruct,
mastered,
is_trace: deck.is_trace(),
examable,
has_exam,
badge_depth,
badge_dotted,
new_cards,
}
}
fn file_name(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub fn workspace_last_progress(folder: &Path) -> Option<String> {
let ts = Store::open(workspace::store_path(folder))
.ok()?
.last_review_ms()?;
let now = crate::time::now_ms();
Some(if now > ts {
format!("{} ago", crate::time::humanize_ms(now - ts))
} else {
"just now".to_string()
})
}
fn stem(name: &str) -> String {
name.strip_suffix(".txt").unwrap_or(name).to_string()
}
fn location_hint(path: &Path, decks_dir: &Path) -> Option<String> {
let parent = path.parent()?;
if parent == decks_dir {
return None;
}
Some(abbreviate_home(parent))
}
fn abbreviate_home(path: &Path) -> String {
directories::BaseDirs::new()
.and_then(|dirs| {
path.strip_prefix(dirs.home_dir()).ok().map(|rest| {
if rest.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~/{}", rest.display())
}
})
})
.unwrap_or_else(|| path.display().to_string())
}
pub struct DeckEntry {
pub name: String,
pub label: String,
pub path: PathBuf,
pub last_used_ms: Option<u64>,
pub is_workspace: bool,
pub description: Option<String>,
pub members: Vec<DeckEntry>,
pub path_hint: Option<String>,
pub icon: Option<PathBuf>,
}
pub fn catalog(decks_dir: &Path, recent: &RecentDecks) -> Vec<DeckEntry> {
build_candidates(decks_dir, recent)
.into_iter()
.map(|c| {
if c.is_workspace {
let (label, description, members, icon) = match workspace::Workspace::load(&c.path)
{
Ok(ws) => {
let members = ws
.members
.iter()
.map(|m| {
let file = file_name(m);
DeckEntry {
name: format!("{}/{}", c.name, file),
label: deck_label(m).unwrap_or_else(|| stem(&file)),
path: m.clone(),
last_used_ms: None,
is_workspace: false,
description: None,
members: Vec::new(),
path_hint: None, icon: None,
}
})
.collect();
(ws.display_name(), ws.description, members, ws.icon)
}
Err(_) => (c.name.clone(), None, Vec::new(), None),
};
DeckEntry {
path_hint: location_hint(&c.path, decks_dir),
name: c.name,
label,
path: c.path,
last_used_ms: c.last_used_ms,
is_workspace: true,
description,
members,
icon,
}
} else {
DeckEntry {
label: deck_label(&c.path).unwrap_or_else(|| stem(&c.name)),
path_hint: location_hint(&c.path, decks_dir),
name: c.name,
path: c.path,
last_used_ms: c.last_used_ms,
is_workspace: false,
description: None,
members: Vec::new(),
icon: None,
}
}
})
.collect()
}
fn deck_label(path: &Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
parser::parse_title(&text).or_else(|| parser::parse_trace(&text).map(|t| title::condense(&t)))
}
pub fn member_parents(members: &[PathBuf], decks_dir: &Path) -> Vec<Option<usize>> {
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let canonical: Vec<PathBuf> = members.iter().map(|m| canon(m)).collect();
members
.iter()
.enumerate()
.map(|(i, m)| {
let deck = Deck::load(m).ok()?;
deck.requires.iter().find_map(|req| {
let dep = canon(&deck::resolve_dep(req, Some(decks_dir), m.parent())?);
canonical.iter().position(|c| *c == dep).filter(|&j| j != i)
})
})
.collect()
}
pub fn dependency_forest<K: Ord>(parent: &[Option<usize>], key: &[K]) -> Vec<(usize, String)> {
let n = parent.len();
let mut children = vec![Vec::new(); n];
let mut roots = Vec::new();
for (i, p) in parent.iter().enumerate() {
match *p {
Some(p) if p < n && p != i => children[p].push(i),
_ => roots.push(i),
}
}
roots.sort_by(|a, b| key[*a].cmp(&key[*b]));
for kids in &mut children {
kids.sort_by(|a, b| key[*a].cmp(&key[*b]));
}
let mut out = Vec::new();
let mut visited = vec![false; n];
let root_count = roots.len();
for (k, &r) in roots.iter().enumerate() {
visit_node(
r,
"",
k + 1 == root_count,
true,
&children,
&mut visited,
&mut out,
);
}
for i in 0..n {
if !visited[i] {
visit_node(i, "", true, true, &children, &mut visited, &mut out);
}
}
out
}
fn visit_node(
i: usize,
ancestor: &str,
is_last: bool,
is_root: bool,
children: &[Vec<usize>],
visited: &mut [bool],
out: &mut Vec<(usize, String)>,
) {
if visited[i] {
return;
}
visited[i] = true;
let connector = if is_root {
""
} else if is_last {
"└─ "
} else {
"├─ "
};
out.push((i, format!("{ancestor}{connector}")));
let child_ancestor = if is_root {
String::new()
} else if is_last {
format!("{ancestor} ")
} else {
format!("{ancestor}│ ")
};
let kids = &children[i];
for (k, &c) in kids.iter().enumerate() {
visit_node(
c,
&child_ancestor,
k + 1 == kids.len(),
false,
children,
visited,
out,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dependency_forest_nests_dependents_under_prerequisites() {
let names: Vec<String> = ["data-model", "leitner", "grading", "sm2", "queue-building"]
.iter()
.map(|s| s.to_string())
.collect();
let parent = vec![None, Some(0), Some(1), Some(0), Some(0)];
let order = dependency_forest(&parent, &names);
assert_eq!(
vec![
(0, "".to_string()),
(1, "├─ ".to_string()),
(2, "│ └─ ".to_string()),
(4, "├─ ".to_string()),
(3, "└─ ".to_string()),
],
order
);
}
#[test]
fn dependency_forest_survives_a_cycle() {
let names = vec!["a".to_string(), "b".to_string()];
let parent = vec![Some(1), Some(0)];
let order = dependency_forest(&parent, &names);
let mut indices: Vec<usize> = order.iter().map(|(i, _)| *i).collect();
indices.sort();
assert_eq!(vec![0, 1], indices);
}
#[test]
fn build_candidates_orders_recent_first_then_alpha() {
let dir = tempfile::tempdir().unwrap();
for n in ["zeta.txt", "alpha.txt", "mid.txt"] {
std::fs::write(dir.path().join(n), "# f\n\tb\n").unwrap();
}
let recent_path = dir.path().join("recent.json");
let mut recent = RecentDecks::load(&recent_path);
recent.record(&[dir.path().join("mid.txt")], 1000);
let cands = build_candidates(dir.path(), &recent);
let names: Vec<&str> = cands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(vec!["mid.txt", "alpha.txt", "zeta.txt"], names);
assert!(cands[0].last_used_ms.is_some());
assert!(cands[1].last_used_ms.is_none());
}
#[test]
fn catalog_mirrors_candidate_order_and_paths() {
let dir = tempfile::tempdir().unwrap();
for n in ["zeta.txt", "alpha.txt"] {
std::fs::write(dir.path().join(n), "# f\n\tb\n").unwrap();
}
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
recent.record(&[dir.path().join("zeta.txt")], 1000);
let entries = catalog(dir.path(), &recent);
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(vec!["zeta.txt", "alpha.txt"], names); assert_eq!(dir.path().join("zeta.txt"), entries[0].path);
assert!(entries[0].last_used_ms.is_some());
}
#[test]
fn deck_label_condenses_a_trace_path_question_instead_of_the_slug() {
let dir = tempfile::tempdir().unwrap();
let trace = dir.path().join("06-how-a-digest-becomes-verified.txt");
std::fs::write(
&trace,
"% trace: how a transaction digest becomes verified effects and events: \
fetch the checkpoint, derive the committee, then verify\n",
)
.unwrap();
assert_eq!(
Some("How a Transaction Digest Becomes Verified Effects and Events".to_string()),
deck_label(&trace),
);
let titled = dir.path().join("01-the-domain-model.txt");
std::fs::write(&titled, "% title: The Domain Model\n# f\n\tb\n").unwrap();
assert_eq!(Some("The Domain Model".to_string()), deck_label(&titled));
let plain = dir.path().join("plain.txt");
std::fs::write(&plain, "# f\n\tb\n").unwrap();
assert_eq!(None, deck_label(&plain));
}
#[test]
fn location_hint_only_for_entries_outside_the_decks_dir() {
let home = directories::BaseDirs::new()
.unwrap()
.home_dir()
.to_path_buf();
let decks = home.join("decks");
assert_eq!(None, location_hint(&decks.join("foo.txt"), &decks));
assert_eq!(None, location_hint(&decks.join("english"), &decks));
assert_eq!(
Some("~/other".to_string()),
location_hint(&home.join("other").join("x.txt"), &decks)
);
assert_eq!(
Some("/tmp".to_string()),
location_hint(Path::new("/tmp/x.txt"), &decks)
);
}
#[test]
fn catalog_surfaces_workspace_with_qualified_members() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("english");
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("a.txt"), "# a\n\tb\n").unwrap();
std::fs::write(ws.join("b.txt"), "# c\n\td\n").unwrap();
std::fs::write(ws.join(workspace::MANIFEST), "title = \"English\"\n").unwrap();
let recent = RecentDecks::load(dir.path().join("recent.json"));
let entries = catalog(dir.path(), &recent);
let w = entries
.iter()
.find(|e| e.is_workspace)
.expect("workspace entry");
assert_eq!("english", w.name); assert_eq!("English", w.label); let members: Vec<&str> = w.members.iter().map(|m| m.name.as_str()).collect();
assert_eq!(vec!["english/a.txt", "english/b.txt"], members);
}
#[test]
fn build_candidates_skips_missing_recent_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("real.txt"), "# f\n\tb\n").unwrap();
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
recent.record(&[dir.path().join("deleted.txt")], 1000);
let cands = build_candidates(dir.path(), &recent);
let names: Vec<&str> = cands.iter().map(|c| c.name.as_str()).collect();
assert_eq!(vec!["real.txt"], names);
}
fn graduated_not_due(now: u64) -> crate::store::FsrsState {
crate::store::FsrsState {
state: 2, scheduled_days: 30,
due_ms: now + 30 * 86_400_000,
..Default::default()
}
}
fn insert_due_virtual_card(store: &mut Store, subject: &str) {
let text = "# virtual front\n\tvirtual back\n".to_string();
let id = crate::parser::parse_str(subject, &text).unwrap()[0].id();
store.insert_virtual(crate::store::VirtualCard {
id,
kind: crate::store::VirtualKind::Remediation,
parent: subject.to_string(),
text,
created_ms: 0,
});
store.get_or_insert(id, 0);
}
#[test]
fn deck_status_reviewable_true_when_only_a_virtual_card_is_due() {
let dir = tempfile::tempdir().unwrap();
let deck_path = dir.path().join("rust.txt");
std::fs::write(&deck_path, "# q1\n\ta1\n").unwrap();
let deck = Deck::load(&deck_path).unwrap();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let now = session::now_ms();
let card_id = deck.cards[0].id();
let entry = store.get_or_insert(card_id, now);
entry.recognized_ms = Some(now);
entry.recall = Some(graduated_not_due(now));
entry.reconstruct = Some(graduated_not_due(now));
let status = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert_eq!("done ✓", status.badge);
assert!(!status.reviewable);
insert_due_virtual_card(&mut store, &deck.subject);
let status = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert!(status.reviewable);
assert_eq!("done ✓", status.badge); }
#[test]
fn a_recall_settled_deck_is_still_reviewable_at_reconstruct() {
let dir = tempfile::tempdir().unwrap();
let deck_path = dir.path().join("rust.txt");
std::fs::write(&deck_path, "# q1\n\ta1\n").unwrap();
let deck = Deck::load(&deck_path).unwrap();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let now = session::now_ms();
let card_id = deck.cards[0].id();
store.get_or_insert(card_id, now).recall = Some(mature(now, 25.0));
let status = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert!(!status.reviewable_recall, "nothing due at recall");
assert!(
status.reviewable_reconstruct,
"due now at reconstruct via the immediacy rule"
);
assert!(status.reviewable, "reviewable overall via reconstruct");
}
#[test]
fn recognize_reviewability_tracks_unrecognized_cards() {
let dir = tempfile::tempdir().unwrap();
let deck_path = dir.path().join("rust.txt");
std::fs::write(&deck_path, "# q1\n\ta1\n# q2\n\ta2\n").unwrap();
let deck = Deck::load(&deck_path).unwrap();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let now = session::now_ms();
store.get_or_insert(deck.cards[0].id(), now).recognized_ms = Some(now);
let status = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert!(status.reviewable_recognize);
store.get_or_insert(deck.cards[1].id(), now).recognized_ms = Some(now);
let status = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert!(!status.reviewable_recognize);
}
#[test]
fn deck_status_total_ignores_virtual_cards() {
let dir = tempfile::tempdir().unwrap();
let deck_path = dir.path().join("rust.txt");
std::fs::write(&deck_path, "# q1\n\ta1\n# q2\n\ta2\n# q3\n\ta3\n").unwrap();
let deck = Deck::load(&deck_path).unwrap();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let now = session::now_ms();
store.get_or_insert(deck.cards[0].id(), now).recall = Some(graduated_not_due(now));
let before = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert_eq!("1/3", before.badge);
insert_due_virtual_card(&mut store, &deck.subject);
let after = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert_eq!(before.badge, after.badge);
}
fn mature(now: u64, stability: f64) -> crate::store::FsrsState {
crate::store::FsrsState {
state: 2,
stability,
scheduled_days: 30,
due_ms: now + 30 * 86_400_000,
..Default::default()
}
}
#[test]
fn the_highest_currently_solid_depth_wins_the_badge() {
let dir = tempfile::tempdir().unwrap();
let deck_path = dir.path().join("rust.txt");
std::fs::write(&deck_path, "# q1\n\ta1\n").unwrap();
let deck = Deck::load(&deck_path).unwrap();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let now = session::now_ms();
let card_id = deck.cards[0].id();
let entry = store.get_or_insert(card_id, now);
entry.recall = Some(mature(now, 25.0));
entry.reconstruct = Some(mature(now, 30.0));
let status = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert_eq!(Some(Depth::Reconstruct), status.badge_depth);
assert!(!status.badge_dotted);
}
#[test]
fn an_earned_but_lapsed_badge_shows_dotted() {
let dir = tempfile::tempdir().unwrap();
let deck_path = dir.path().join("rust.txt");
std::fs::write(&deck_path, "# q1\n\ta1\n").unwrap();
let deck = Deck::load(&deck_path).unwrap();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let now = session::now_ms();
let card_id = deck.cards[0].id();
store.get_or_insert(card_id, now).recall = Some(mature(now, 25.0));
crate::store::note_badges(&mut store, &deck.subject, &deck.cards, now);
store.get_or_insert(card_id, now).recall = Some(mature(now, 5.0));
let status = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert_eq!(Some(Depth::Recall), status.badge_depth);
assert!(status.badge_dotted);
}
#[test]
fn new_cards_flag_fires_without_touching_badges() {
let dir = tempfile::tempdir().unwrap();
let deck_path = dir.path().join("rust.txt");
std::fs::write(&deck_path, "# q1\n\ta1\n# q2\n\ta2\n").unwrap();
let deck = Deck::load(&deck_path).unwrap();
let mut store = Store::open(dir.path().join("progress.json")).unwrap();
let now = session::now_ms();
store.get_or_insert(deck.cards[0].id(), now).recall = Some(graduated_not_due(now));
let status = deck_status(&deck, &store, None, false, ReviewConfig::default());
assert!(status.new_cards);
assert_eq!("1/2", status.badge);
assert_eq!(None, status.badge_depth);
assert!(!status.badge_dotted);
}
}