use std::path::{Path, PathBuf};
use crate::{
config::ReviewConfig,
deck::Deck,
depth::Depth,
scheduler::Fsrs,
session,
store::{self, Store},
workspace,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeckSummary {
pub title: String,
pub path: PathBuf,
pub is_workspace: bool,
pub due: bool,
}
pub fn list_root(root: &Path, review: &ReviewConfig, now_ms: u64) -> Vec<DeckSummary> {
if workspace::is_workspace(root) {
return vec![folder_summary(root, root, review, now_ms)];
}
let root_store = Store::open(workspace::root_store_path(root)).ok();
let mut names: Vec<PathBuf> = std::fs::read_dir(root)
.map(|entries| entries.flatten().map(|e| e.path()).collect())
.unwrap_or_default();
names.sort();
let mut out = Vec::new();
for path in names {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.') {
continue;
}
if path.is_dir() && workspace::has_decks(&path) {
out.push(folder_summary(root, &path, review, now_ms));
} else if path.is_file() && path.extension().is_some_and(|e| e == "txt") {
out.push(deck_summary(&path, root_store.as_ref(), review, now_ms));
}
}
out
}
pub fn list_members(
root: &Path,
dir: &Path,
review: &ReviewConfig,
now_ms: u64,
) -> Vec<DeckSummary> {
let store = member_store(root, dir);
workspace::Workspace::load(dir)
.map(|ws| {
ws.members
.iter()
.map(|m| deck_summary(m, store.as_ref(), review, now_ms))
.collect()
})
.unwrap_or_default()
}
pub fn sync_conflicts_under(root: &Path) -> Vec<PathBuf> {
let mut out = store::sync_conflicts(&workspace::root_store_path(root));
let mut entries: Vec<PathBuf> = std::fs::read_dir(root)
.map(|entries| entries.flatten().map(|e| e.path()).collect())
.unwrap_or_default();
entries.sort();
for path in entries {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.') {
continue;
}
if path.is_dir() && workspace::is_workspace(&path) {
out.extend(store::sync_conflicts(&workspace::store_path(&path)));
}
}
out
}
fn member_store(root: &Path, dir: &Path) -> Option<Store> {
let path = if workspace::is_workspace(dir) {
workspace::store_path(dir)
} else {
workspace::root_store_path(root)
};
Store::open(path).ok()
}
fn folder_summary(root: &Path, dir: &Path, review: &ReviewConfig, now_ms: u64) -> DeckSummary {
let title = workspace::Workspace::load(dir)
.map(|ws| ws.display_name())
.unwrap_or_else(|_| {
dir.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
});
let due = list_members(root, dir, review, now_ms)
.iter()
.any(|m| m.due);
DeckSummary {
title,
path: dir.to_path_buf(),
is_workspace: true,
due,
}
}
fn deck_summary(
path: &Path,
store: Option<&Store>,
review: &ReviewConfig,
now_ms: u64,
) -> DeckSummary {
let stem = path
.file_stem()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let (title, due) = match (Deck::load(path), store) {
(Ok(deck), Some(store)) => {
let due = deck_due(&deck, store, review, now_ms);
(deck.display_name(), due)
}
(Ok(deck), None) => (deck.display_name(), false),
(Err(_), _) => (stem.clone(), false),
};
DeckSummary {
title: if title.is_empty() { stem } else { title },
path: path.to_path_buf(),
is_workspace: false,
due,
}
}
fn deck_due(deck: &Deck, store: &Store, review: &ReviewConfig, now_ms: u64) -> bool {
let scheduler = Fsrs::new(review.retention, review.acquire_cooldown_ms);
let retire = review.retire_after_days;
[Depth::Recognize, Depth::Recall, Depth::Reconstruct]
.into_iter()
.any(|depth| session::has_reviewable(&deck.cards, store, &scheduler, depth, now_ms, retire))
|| session::has_reviewable_virtual(store, &deck.subject, &scheduler, now_ms, retire)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scheduler::{Grade, Scheduler};
const T0: u64 = 1_000_000;
const MUCH_LATER: u64 = T0 + 30 * 86_400_000;
fn write(path: &Path, text: &str) {
std::fs::write(path, text).unwrap();
}
fn settle(store_path: &Path, deck_path: &Path) {
let mut store = Store::open(store_path).unwrap();
let deck = Deck::load(deck_path).unwrap();
let scheduler = Fsrs::default();
for card in &deck.cards {
let state = store.get_or_insert(card.id(), T0);
state.recognized_ms = Some(T0);
scheduler.apply(state, Depth::Recall, Grade::Pass, T0, false);
scheduler.apply(state, Depth::Reconstruct, Grade::Pass, T0, false);
}
store.save().unwrap();
}
#[test]
fn lists_titles_and_kinds_and_skips_dot_names() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("b-loose.txt"), "% title: Loose Deck\n# q\n\ta\n");
std::fs::create_dir(root.join("a-ws")).unwrap();
write(&root.join("a-ws/alix.toml"), "title = \"My Workspace\"\n");
write(&root.join("a-ws/m.txt"), "# q\n\ta\n");
std::fs::create_dir(root.join("c-plain")).unwrap();
write(&root.join("c-plain/d.txt"), "# q\n\ta\n");
std::fs::create_dir(root.join(".hidden")).unwrap();
write(&root.join(".hidden/x.txt"), "# q\n\ta\n");
write(&root.join("notes.md"), "not a deck");
let rows = list_root(root, &ReviewConfig::default(), T0);
let names: Vec<(&str, bool)> = rows
.iter()
.map(|r| (r.title.as_str(), r.is_workspace))
.collect();
assert_eq!(
names,
[
("My Workspace", true),
("Loose Deck", false),
("c-plain", true),
]
);
}
#[test]
fn a_root_that_is_a_workspace_collapses_to_one_entry() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("alix.toml"), "title = \"The Root\"\n");
write(&root.join("a.txt"), "# q\n\ta\n");
let rows = list_root(root, &ReviewConfig::default(), T0);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "The Root");
assert!(rows[0].is_workspace);
assert!(rows[0].due, "a fresh deck has new cards");
let members = list_members(root, root, &ReviewConfig::default(), T0);
assert_eq!(members.len(), 1);
}
#[test]
fn due_reads_each_entrys_own_store_and_the_injected_clock() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("loose.txt"), "# q\n\ta\n");
std::fs::create_dir(root.join("ws")).unwrap();
write(&root.join("ws/alix.toml"), "");
write(&root.join("ws/m.txt"), "# q\n\ta\n");
settle(
&workspace::store_path(&root.join("ws")),
&root.join("ws/m.txt"),
);
settle(&workspace::root_store_path(root), &root.join("loose.txt"));
let just_after = list_root(root, &ReviewConfig::default(), T0 + 1_000);
assert!(
just_after.iter().all(|r| !r.due),
"everything settled: {just_after:?}"
);
let later = list_root(root, &ReviewConfig::default(), MUCH_LATER);
assert!(later.iter().all(|r| r.due), "schedules elapsed: {later:?}");
}
#[test]
fn sync_conflicts_under_covers_the_root_and_workspace_stores() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(&root.join("loose.txt"), "# q\n\ta\n");
std::fs::create_dir(root.join("ws")).unwrap();
write(&root.join("ws/alix.toml"), "");
write(&root.join("ws/m.txt"), "# q\n\ta\n");
let root_conflict = root.join("progress.sync-conflict-20260714-101112-AAAAAAA.json");
let ws_conflict = root.join("ws/progress.sync-conflict-20260715-101112-BBBBBBB.json");
write(&root_conflict, "{}");
write(&ws_conflict, "{}");
write(
&root.join("ws/notes.sync-conflict-20260715-101112-CCCCCCC.json"),
"{}",
);
assert_eq!(sync_conflicts_under(root), vec![root_conflict, ws_conflict]);
assert_eq!(
sync_conflicts_under(&root.join("nowhere")),
Vec::<PathBuf>::new()
);
}
#[test]
fn unreadable_entries_degrade_instead_of_failing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(
&root.join("broken.txt"),
"# q\n\t% reveal: cloze\n\tno holes here\n",
);
write(&root.join("ok.txt"), "# q\n\ta\n");
let rows = list_root(root, &ReviewConfig::default(), T0);
let broken = rows.iter().find(|r| r.title == "broken").expect("listed");
assert!(!broken.due);
assert!(rows.iter().any(|r| r.title == "ok" && r.due));
assert_eq!(
list_root(&root.join("nowhere"), &ReviewConfig::default(), T0),
[]
);
}
}