use std::{
collections::HashMap,
io::{IsTerminal, Write},
net::{Ipv4Addr, SocketAddr},
path::{Path, PathBuf},
};
use alix::{
answer::Mode,
augment::{self, AugmentCache, Topology, TopologyOrder},
browse,
card::{Card, Frontend},
config::{self, Config, Strictness},
deck::{Deck, DeckSettings, DeckState},
generate, import, parser, picker,
recent::{self, RecentDecks},
scheduler::SchedulerKind,
serve,
session::{Order, Session, SessionOptions, histogram},
store::{Store, default_store_path},
time::{humanize_ms, now_ms},
trace::{Phase, SourceBase, Trace, Walk},
tui::{self, AfterReview, App},
workspace,
};
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
use ratatui::DefaultTerminal;
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
review: ReviewArgs,
}
#[derive(Subcommand)]
enum Command {
Review(ReviewArgs),
Stats(DeckArgs),
List(DeckArgs),
Reset(ResetArgs),
Check {
#[arg(required = true)]
decks: Vec<PathBuf>,
},
Browse(BrowseArgs),
#[command(subcommand)]
Deck(DeckAction),
Import(ImportArgs),
Exam(ExamArgs),
Trace(TraceArgs),
Explore(ExploreArgs),
Workspace(WorkspaceArgs),
#[command(visible_alias = "require")]
Deps {
deck: PathBuf,
},
Config {
#[arg(long)]
init: bool,
},
}
#[derive(Args)]
struct WorkspaceArgs {
dir: PathBuf,
#[arg(long)]
store: Option<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Args)]
struct ExploreArgs {
source: PathBuf,
#[arg(long)]
goal: Option<String>,
#[arg(long)]
into: Option<PathBuf>,
#[arg(long, requires = "into")]
title: Option<String>,
#[arg(long, requires = "into", value_parser = clap::value_parser!(u8).range(1..=5))]
unlock_stage: Option<u8>,
#[arg(long, requires = "into")]
force: bool,
#[arg(long, requires = "into", conflicts_with = "walk")]
build: bool,
#[arg(long, requires = "build")]
icon: Option<PathBuf>,
#[arg(long, conflicts_with = "into")]
walk: bool,
#[arg(short, long, requires = "walk")]
output: Option<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Args)]
struct GenerateDeckArgs {
source: String,
#[arg(short, long)]
output: Option<String>,
#[arg(long)]
cards: Option<usize>,
#[arg(long)]
review: bool,
#[arg(long)]
print: bool,
#[arg(long)]
force: bool,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Subcommand)]
enum DeckAction {
Generate(GenerateDeckArgs),
Augment(AugmentArgs),
}
#[derive(Args)]
struct AugmentArgs {
deck: PathBuf,
#[arg(long, value_enum)]
target: AugmentTarget,
#[arg(long)]
with: Option<String>,
#[arg(long)]
store: Option<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum AugmentTarget {
Choices,
Notes,
Questions,
Keypoints,
Topology,
Format,
}
#[derive(Args)]
struct ImportArgs {
file: PathBuf,
#[arg(short, long)]
output: Option<String>,
#[arg(long)]
print: bool,
#[arg(long)]
force: bool,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Args)]
struct ExamArgs {
deck: PathBuf,
#[arg(long)]
questions: Option<usize>,
#[arg(long, value_enum)]
strictness: Option<Strictness>,
#[arg(long)]
store: Option<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Args)]
struct TraceArgs {
deck: PathBuf,
#[arg(long)]
map: bool,
#[arg(long, conflicts_with = "map")]
build: bool,
#[arg(long, conflicts_with_all = ["map", "build"])]
suggest: bool,
#[arg(long)]
grade: bool,
#[arg(short, long, value_enum)]
scheduler: Option<SchedulerKind>,
#[command(flatten)]
serve: ServeOpts,
#[arg(long)]
store: Option<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Args)]
struct ServeOpts {
#[arg(long)]
serve: bool,
#[arg(long, requires = "serve")]
port: Option<u16>,
#[arg(long, requires = "serve")]
lan: bool,
}
#[derive(Args)]
struct BrowseArgs {
decks: Vec<PathBuf>,
#[command(flatten)]
serve: ServeOpts,
}
#[derive(Args)]
struct DeckArgs {
#[arg(required = true)]
decks: Vec<PathBuf>,
#[arg(short, long, value_enum, default_value_t)]
scheduler: SchedulerKind,
#[arg(long)]
store: Option<PathBuf>,
}
#[derive(Args)]
struct ResetArgs {
decks: Vec<PathBuf>,
#[arg(long)]
card: Option<String>,
#[arg(long, conflicts_with_all = ["card", "all"])]
cards: bool,
#[arg(long, conflicts_with_all = ["decks", "card"])]
all: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
store: Option<PathBuf>,
}
#[derive(Args)]
struct ReviewArgs {
decks: Vec<PathBuf>,
#[arg(short, long, value_enum)]
mode: Option<Mode>,
#[arg(short, long, value_enum)]
scheduler: Option<SchedulerKind>,
#[arg(short, long, value_enum)]
order: Option<Order>,
#[arg(long)]
topology: Option<String>,
#[arg(long)]
region: Option<String>,
#[arg(short, long, default_value_t = 10)]
new: usize,
#[arg(short, long)]
limit: Option<usize>,
#[arg(long)]
cram: bool,
#[arg(long, default_value_t = 2)]
max_typos: usize,
#[arg(long)]
store: Option<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
#[command(flatten)]
serve: ServeOpts,
}
fn main() -> Result<()> {
alix::store::migrate_legacy_data_dir();
let cli = Cli::parse();
match cli.command {
None => review(cli.review),
Some(Command::Review(args)) => review(args),
Some(Command::Stats(args)) => stats(args),
Some(Command::List(args)) => list(args),
Some(Command::Reset(args)) => reset(args),
Some(Command::Check { decks }) => check(decks),
Some(Command::Browse(args)) => browse(args),
Some(Command::Deck(action)) => match action {
DeckAction::Generate(args) => deck_cmd(args),
DeckAction::Augment(args) => augment_cmd(args),
},
Some(Command::Import(args)) => import_cmd(args),
Some(Command::Exam(args)) => exam_cmd(args),
Some(Command::Trace(args)) => trace_cmd(args),
Some(Command::Explore(args)) => explore_cmd(args),
Some(Command::Workspace(args)) => workspace_cmd(args),
Some(Command::Deps { deck }) => deps_cmd(deck),
Some(Command::Config { init }) => config_cmd(init),
}
}
fn open_store(path: Option<PathBuf>) -> Result<Store> {
let path = match path {
Some(path) => path,
None => default_store_path().context("cannot determine the data directory")?,
};
Store::open(&path).context("cannot open the progress store")
}
fn store_path_for(decks: &[PathBuf], cli_override: Option<&Path>) -> Option<PathBuf> {
if let Some(path) = cli_override {
return Some(path.to_path_buf());
}
let mut stores = decks.iter().map(|deck| {
deck.parent()
.filter(|p| workspace::is_workspace(p))
.map(workspace::store_path)
});
match stores.next() {
Some(Some(first)) if stores.all(|s| s.as_ref() == Some(&first)) => Some(first),
_ => None,
}
}
fn store_for(decks: &[PathBuf], cli_override: Option<PathBuf>) -> Result<Store> {
open_store(store_path_for(decks, cli_override.as_deref()))
}
type LoadedDecks = (
Vec<Card>,
String,
std::collections::HashMap<String, tui::DeckInfo>,
Vec<DeckSettings>,
);
fn load_decks(paths: &[PathBuf], defaults: &HashMap<String, DeckSettings>) -> Result<LoadedDecks> {
let mut cards = Vec::new();
let mut names = Vec::new();
let mut decks = std::collections::HashMap::new();
let mut settings = Vec::new();
for path in paths {
let deck = match path
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| defaults.get(n))
{
Some(ws) => Deck::load_with_defaults(path, ws)?,
None => Deck::load(path)?,
};
names.push(deck.display_name());
decks.insert(
deck.subject.clone(),
tui::DeckInfo {
path: deck.path.clone(),
links: deck.reference_links(),
source_root: deck.source_root(),
source_access: false,
source_base: SourceBase::for_deck(&deck),
},
);
settings.push(deck.settings);
cards.extend(deck.cards);
}
Ok((cards, names.join(", "), decks, settings))
}
struct Expanded {
decks: Vec<PathBuf>,
defaults: HashMap<String, DeckSettings>,
}
fn expand_workspaces(deck_paths: &[PathBuf]) -> Result<Expanded> {
let mut decks = Vec::new();
let mut defaults: HashMap<String, DeckSettings> = HashMap::new();
for path in deck_paths {
if let Some(parent) = path.parent()
&& parent.join(workspace::MANIFEST).is_file()
&& let Ok(ws) = workspace::Workspace::load(parent)
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
defaults.insert(name.to_string(), ws.settings);
}
decks.push(path.clone());
}
Ok(Expanded { decks, defaults })
}
fn resolve<T: Copy + PartialEq>(
name: &str,
cli: Option<T>,
declared: impl Iterator<Item = Option<T>>,
default: T,
) -> T {
if let Some(value) = cli {
return value;
}
let mut distinct: Vec<T> = Vec::new();
for value in declared.flatten() {
if !distinct.contains(&value) {
distinct.push(value);
}
}
match distinct.as_slice() {
[] => default,
[only] => *only,
_ => {
eprintln!("warning: decks disagree on `{name}`; using the default");
default
}
}
}
#[expect(clippy::too_many_arguments)] fn pick_decks_if_empty(
terminal: Option<&mut DefaultTerminal>,
decks: Vec<PathBuf>,
config: &Config,
recent: &RecentDecks,
store: &Store,
enforce_locks: bool,
gate_reviewable: bool,
start_in: Option<&Path>,
focus: Option<&Path>,
) -> Result<Option<picker::Picked>> {
if !decks.is_empty() {
return Ok(Some(picker::Picked {
decks,
workspace: None, }));
}
if !std::io::stdout().is_terminal() {
bail!("no deck files given; try `alix <deck.txt>...` or `alix --help`");
}
let terminal = terminal.expect("the interactive picker needs a terminal");
let decks_dir = config.decks_dir().context("cannot determine ~/decks")?;
let picked = picker::pick(
terminal,
&decks_dir,
recent,
store,
enforce_locks,
gate_reviewable,
start_in,
focus,
&config.picker,
)?;
Ok((!picked.decks.is_empty()).then_some(picked))
}
struct ReviewSession {
session: Session,
store: Store,
mode_override: Option<Mode>,
label: String,
decks: HashMap<String, tui::DeckInfo>,
config: Config,
hidden: usize,
topology_name: Option<String>,
}
enum Started {
Review(Box<ReviewSession>),
Exam {
deck: Box<Deck>,
store: Store,
config: Box<Config>,
},
Walk {
trace: Box<Trace>,
scheduler: SchedulerKind,
store: Store,
},
}
struct LoadedSession {
started: Started,
workspace: Option<PathBuf>,
focus: Option<PathBuf>,
}
struct ReviewBuild {
session: Session,
label: String,
decks: HashMap<String, tui::DeckInfo>,
hidden: usize,
topology_name: Option<String>,
}
#[expect(
clippy::too_many_arguments,
reason = "each is a distinct build input; grouping them would only hide the dependency"
)]
fn build_review(
deck_paths: Vec<PathBuf>,
args: &ReviewArgs,
config: &Config,
store: &Store,
recent: &mut RecentDecks,
target: Frontend,
topology_sel: Option<&str>,
region_sel: Option<&str>,
) -> Result<ReviewBuild> {
let [deck] = deck_paths.as_slice() else {
bail!("review one deck at a time (merging decks was removed)");
};
if workspace::has_decks(deck) {
bail!(
"`{}` is a workspace — review a deck inside it, or open it with `alix workspace`",
deck.display()
);
}
let expanded = expand_workspaces(&deck_paths)?;
let (cards, deck_label, mut decks, settings) = load_decks(&expanded.decks, &expanded.defaults)?;
for info in decks.values_mut() {
let workspace_override = info
.path
.parent()
.filter(|p| workspace::is_workspace(p))
.and_then(workspace::manifest_source_access);
info.source_access = workspace_override.unwrap_or(config.ask.source_access);
}
let label = deck_label;
let deck_ids: std::collections::HashSet<u64> = cards.iter().map(|c| c.id()).collect();
let total = cards.len();
let mut cards: Vec<_> = cards
.into_iter()
.filter(|c| matches!(c.frontend(), Frontend::Any) || c.frontend() == target)
.collect();
let hidden = total - cards.len();
let augment = AugmentCache::open(augment::augment_path_for(store.path()));
for card in &mut cards {
augment.apply_format(card);
if let Some(note) = augment.note(card.id()) {
card.append_note(&[note.to_string()]);
}
}
let topology = resolve_topology(topology_sel, &augment, &deck_ids)?;
let topology_name = topology.map(|t| t.name.clone());
let topology_order = topology.map(|t| TopologyOrder::from_walk(&t.walk));
if let Some(region_name) = region_sel {
let Some(topology) = topology else {
bail!("--region needs a topology — pass --topology, or augment one for this deck");
};
let Some(region_ids) = topology.region_cards(region_name) else {
bail!(
"no region named `{region_name}` in topology `{}`",
topology.name
);
};
let ids: std::collections::HashSet<u64> = region_ids.iter().copied().collect();
cards.retain(|c| ids.contains(&c.id()));
}
let target_settings: Vec<&DeckSettings> = settings.iter().collect();
let scheduler = resolve(
"scheduler",
args.scheduler,
target_settings.iter().map(|s| s.scheduler),
SchedulerKind::default(),
);
let order = resolve(
"order",
args.order,
target_settings.iter().map(|s| s.order),
Order::default(),
);
let options = SessionOptions {
max_new: args.new,
limit: args.limit,
cram: args.cram,
order,
topology: topology_order,
};
let session = Session::new(cards, store, scheduler, options, now_ms());
if !session.is_finished() {
recent.record(&deck_paths, now_ms());
let _ = recent.save();
}
Ok(ReviewBuild {
session,
label,
decks,
hidden,
topology_name,
})
}
fn resolve_topology<'a>(
name: Option<&str>,
augment: &'a AugmentCache,
deck_ids: &std::collections::HashSet<u64>,
) -> Result<Option<&'a Topology>> {
let mine = augment.topologies_for(deck_ids);
match name {
Some(name) => match mine.into_iter().find(|t| t.name == name) {
Some(topology) => Ok(Some(topology)),
None => bail!(
"no topology named `{name}` is cached for this deck — run `alix deck augment <deck> --target topology`"
),
},
None => Ok(match mine.as_slice() {
[single] => Some(*single),
_ => None,
}),
}
}
fn single_trace_to_walk(from_picker: bool, deck_paths: &[PathBuf]) -> Option<Deck> {
if !from_picker {
return None;
}
match deck_paths {
[path] => Deck::load(path).ok().filter(|deck| deck.is_trace()),
_ => None,
}
}
fn load_review_session(
terminal: Option<&mut DefaultTerminal>,
args: &ReviewArgs,
target: Frontend,
start_in: Option<&Path>,
focus: Option<&Path>,
) -> Result<Option<LoadedSession>> {
let config = Config::load(args.config.as_deref())?;
let mut recent = RecentDecks::load(
recent::default_recent_path().context("cannot determine the data directory")?,
);
let from_picker = args.decks.is_empty();
let store = open_store(args.store.clone())?;
let Some(picked) = pick_decks_if_empty(
terminal,
args.decks.clone(),
&config,
&recent,
&store,
true,
!args.cram,
start_in,
focus,
)?
else {
return Ok(None); };
let picker::Picked {
decks: deck_paths,
workspace,
} = picked;
let focus_deck = deck_paths.first().cloned();
let store = store_for(&deck_paths, args.store.clone())?;
if let Some(deck) = single_trace_to_walk(from_picker, &deck_paths) {
let scheduler = args
.scheduler
.or(deck.settings.scheduler)
.unwrap_or_default();
let trace = Trace::from_deck(&deck)?;
return Ok(Some(LoadedSession {
started: Started::Walk {
trace: Box::new(trace),
scheduler,
store,
},
workspace,
focus: focus_deck,
}));
}
if let [path] = deck_paths.as_slice()
&& let Ok(deck) = Deck::load(path)
&& !deck.sources.is_empty()
&& deck.state(&store) == DeckState::ExamDue
&& !alix::deck::is_locked(&deck, config.decks_dir().as_deref(), &store)
{
return Ok(Some(LoadedSession {
started: Started::Exam {
deck: Box::new(deck),
store,
config: Box::new(config),
},
workspace,
focus: focus_deck,
}));
}
let build = build_review(
deck_paths,
args,
&config,
&store,
&mut recent,
target,
args.topology.as_deref(),
args.region.as_deref(),
)?;
Ok(Some(LoadedSession {
started: Started::Review(Box::new(ReviewSession {
session: build.session,
store,
mode_override: args.mode,
label: build.label,
decks: build.decks,
config,
hidden: build.hidden,
topology_name: build.topology_name,
})),
workspace,
focus: focus_deck,
}))
}
fn run_exam_app(deck: Deck, config: &Config, store: Store) -> Result<()> {
exam_app(deck, config, store).run()
}
fn run_exam_app_on(
terminal: &mut DefaultTerminal,
deck: Deck,
config: &Config,
store: Store,
) -> Result<()> {
exam_app(deck, config, store).run_on(terminal)
}
fn browse_one(
path: &std::path::Path,
store_override: Option<PathBuf>,
config: &Config,
terminal: Option<&mut DefaultTerminal>,
) -> Result<()> {
let mut recent = RecentDecks::load(
recent::default_recent_path().context("cannot determine the data directory")?,
);
let decks = vec![path.to_path_buf()];
let deck_store = store_for(&decks, store_override)?;
let build = build_browse(decks, &mut recent, Frontend::Tui)?;
let paths = subject_paths(build.decks);
match terminal {
Some(t) => browse::run_on(
t,
build.cards,
build.label,
config.browse.clone(),
paths,
deck_store,
),
None => browse::run(
build.cards,
build.label,
config.browse.clone(),
paths,
deck_store,
),
}
}
fn exam_app(deck: Deck, config: &Config, store: Store) -> tui::ExamApp {
let strictness = deck
.settings
.exam_strictness
.unwrap_or(config.exam.strictness);
let decks_dir = config.decks_dir();
tui::ExamApp::new(
deck,
strictness,
config.exam.clone(),
config.ask.clone(),
store,
decks_dir,
)
}
fn build_browse(
deck_paths: Vec<PathBuf>,
recent: &mut RecentDecks,
target: Frontend,
) -> Result<BrowseBuild> {
let [deck] = deck_paths.as_slice() else {
bail!("browse one deck at a time (merging decks was removed)");
};
if workspace::has_decks(deck) {
bail!(
"`{}` is a workspace — browse a deck inside it, or open it with `alix workspace`",
deck.display()
);
}
let expanded = expand_workspaces(&deck_paths)?;
let (cards, deck_label, decks, _) = load_decks(&expanded.decks, &expanded.defaults)?;
let label = deck_label;
let mut cards: Vec<_> = cards
.into_iter()
.filter(|c| matches!(c.frontend(), Frontend::Any) || c.frontend() == target)
.collect();
let store = store_for(&expanded.decks, None)?;
let augment = AugmentCache::open(augment::augment_path_for(store.path()));
for card in &mut cards {
augment.apply_format(card);
if let Some(note) = augment.note(card.id()) {
card.append_note(&[note.to_string()]);
}
}
recent.record(&deck_paths, now_ms());
let _ = recent.save();
Ok(BrowseBuild {
cards,
label,
decks,
})
}
struct BrowseBuild {
cards: Vec<Card>,
label: String,
decks: HashMap<String, tui::DeckInfo>,
}
fn serve_addr(port: Option<u16>, lan: bool, config: &Config) -> SocketAddr {
let ip = if lan {
Ipv4Addr::UNSPECIFIED } else {
Ipv4Addr::LOCALHOST };
SocketAddr::from((ip, port.unwrap_or(config.serve.port)))
}
fn subject_paths(decks: HashMap<String, tui::DeckInfo>) -> HashMap<String, PathBuf> {
decks
.into_iter()
.map(|(subject, info)| (subject, info.path))
.collect()
}
fn review(args: ReviewArgs) -> Result<()> {
if args.serve.serve {
return review_serve(args, false);
}
if !args.decks.is_empty() {
return match load_review_session(None, &args, Frontend::Tui, None, None)? {
Some(loaded) => run_started(loaded.started, &args),
None => Ok(()),
};
}
let mut terminal = ratatui::init();
let result = review_loop(&mut terminal, &args);
ratatui::restore();
result
}
fn review_loop(terminal: &mut DefaultTerminal, args: &ReviewArgs) -> Result<()> {
let mut start_in: Option<PathBuf> = None;
let mut focus: Option<PathBuf> = None;
loop {
let Some(loaded) = load_review_session(
Some(&mut *terminal),
args,
Frontend::Tui,
start_in.as_deref(),
focus.as_deref(),
)?
else {
return Ok(()); };
let LoadedSession {
started,
workspace,
focus: launched,
} = loaded;
run_started_on(terminal, started, args)?;
start_in = workspace;
focus = launched;
}
}
fn run_started(started: Started, args: &ReviewArgs) -> Result<()> {
match started {
Started::Exam {
deck,
store,
config,
} => run_exam_app(*deck, &config, store),
Started::Walk {
trace,
scheduler,
mut store,
} => run_walk(*trace, scheduler, &mut store, None).map(|_| ()),
Started::Review(rs) => run_review_tui(*rs, args),
}
}
fn run_started_on(
terminal: &mut DefaultTerminal,
started: Started,
args: &ReviewArgs,
) -> Result<()> {
match started {
Started::Exam {
deck,
store,
config,
} => run_exam_app_on(terminal, *deck, &config, store),
Started::Walk {
trace,
scheduler,
mut store,
} => {
ratatui::restore();
let result = run_walk(*trace, scheduler, &mut store, None).map(|_| ());
*terminal = ratatui::init();
result
}
Started::Review(rs) => run_review_on(terminal, *rs, args),
}
}
fn run_review_on(
terminal: &mut DefaultTerminal,
rs: ReviewSession,
args: &ReviewArgs,
) -> Result<()> {
let ReviewSession {
session,
store,
mode_override,
label,
decks,
config,
hidden,
topology_name,
} = rs;
if session.is_finished() || (hidden > 0 && session.cards().is_empty()) {
return Ok(());
}
let ui_options = alix::tui::Options {
mode_override,
max_typos: args.max_typos,
deck_label: label,
keys: config.keys.clone(),
ask: config.ask.clone(),
decks,
topology_name,
};
let (_stats, next_action) = App::new(session, store, ui_options).run_on(terminal)?;
match next_action {
Some(AfterReview::Exam(path)) => {
let store = store_for(std::slice::from_ref(&path), args.store.clone())?;
let deck = Deck::load(&path)?;
run_exam_app_on(terminal, deck, &config, store)
}
Some(AfterReview::Browse(path)) => {
browse_one(&path, args.store.clone(), &config, Some(terminal))
}
None => Ok(()),
}
}
fn run_review_tui(rs: ReviewSession, args: &ReviewArgs) -> Result<()> {
let ReviewSession {
session,
store,
mode_override,
label,
decks,
config,
hidden,
topology_name,
} = rs;
if hidden > 0 {
if session.cards().is_empty() {
println!(
"All {hidden} card(s) in this deck need the browser. \
Run the same command with --serve to review them."
);
return Ok(());
}
println!("{hidden} card(s) need the browser — run with --serve to review them.");
}
if session.is_finished() {
println!("Nothing to review right now — all cards are on cooldown.");
let now = now_ms();
if let Some(due) = session.next_due_at(&store).filter(|&due| due > now) {
println!("Next card is due in {}.", humanize_ms(due - now));
}
return Ok(());
}
let ui_options = alix::tui::Options {
mode_override,
max_typos: args.max_typos,
deck_label: label,
keys: config.keys.clone(),
ask: config.ask.clone(),
decks,
topology_name,
};
let (stats, next_action) = App::new(session, store, ui_options).run()?;
println!(
"Reviewed {} cards: {} passed, {} failed.",
stats.reviews, stats.passed, stats.failed
);
match next_action {
Some(AfterReview::Exam(path)) => {
let store = store_for(std::slice::from_ref(&path), args.store.clone())?;
let deck = Deck::load(&path)?;
run_exam_app(deck, &config, store)
}
Some(AfterReview::Browse(path)) => browse_one(&path, args.store.clone(), &config, None),
None => Ok(()),
}
}
fn review_serve(args: ReviewArgs, browse_mode: bool) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
let mut recent = RecentDecks::load(
recent::default_recent_path().context("cannot determine the data directory")?,
);
let store = store_for(&args.decks, args.store.clone())?;
let decks_dir = config.decks_dir().context("cannot determine ~/decks")?;
let addr = serve_addr(args.serve.port, args.serve.lan, &config);
let to_build = |b: ReviewBuild| {
let links = b
.decks
.iter()
.map(|(subject, info)| (subject.clone(), info.links.clone()))
.collect();
let source_roots = b
.decks
.iter()
.filter(|(_, info)| info.source_access)
.filter_map(|(subject, info)| {
info.source_root.clone().map(|root| (subject.clone(), root))
})
.collect();
let source_bases = b
.decks
.iter()
.map(|(subject, info)| (subject.clone(), info.source_base.clone()))
.collect();
serve::SessionBuild {
session: b.session,
label: b.label,
decks: subject_paths(b.decks),
links,
source_roots,
source_bases,
topology_name: b.topology_name,
}
};
let to_cards = |b: BrowseBuild| serve::CardsBuild {
cards: b.cards,
label: b.label,
decks: subject_paths(b.decks),
};
let (initial, label) = if args.decks.is_empty() {
(serve::Launch::Picker, "select decks".to_string())
} else if browse_mode {
let cards = to_cards(build_browse(
args.decks.clone(),
&mut recent,
Frontend::Web,
)?);
let label = format!("{} (browse)", cards.label);
(serve::Launch::Browse(cards), label)
} else {
let b = to_build(build_review(
args.decks.clone(),
&args,
&config,
&store,
&mut recent,
Frontend::Web,
args.topology.as_deref(),
args.region.as_deref(),
)?);
let label = b.label.clone();
(serve::Launch::Review(b), label)
};
announce(addr, args.serve.lan, &label);
let opts = serve::ReviewOptions {
mode_override: args.mode,
keys: config.keys.clone(),
picker: config.picker.clone(),
browse: config.browse.clone(),
max_typos: args.max_typos,
ask: config.ask.clone(),
exam: config.exam.clone(),
ai: config.ai.clone(),
};
let build = |paths: Vec<PathBuf>,
topology: Option<&str>,
region: Option<&str>,
store: &Store,
recent: &mut RecentDecks| {
build_review(
paths,
&args,
&config,
store,
recent,
Frontend::Web,
topology,
region,
)
.map(to_build)
};
let build_walk = |paths: &[PathBuf]| -> Result<Option<serve::WalkBuild>> {
match single_trace_to_walk(true, paths) {
Some(deck) => {
let scheduler = args
.scheduler
.or(deck.settings.scheduler)
.unwrap_or_default();
let trace = Trace::from_deck(&deck)?;
Ok(Some(serve::WalkBuild {
walk: Walk::new(trace, scheduler),
scheduler,
}))
}
None => Ok(None),
}
};
let store_for_sel = |paths: &[PathBuf]| store_for(paths, args.store.clone());
let build_browse_sel = |paths: Vec<PathBuf>, recent: &mut RecentDecks| {
build_browse(paths, recent, Frontend::Web).map(to_cards)
};
serve::run_review(
initial,
store,
recent,
decks_dir,
addr,
opts,
build,
build_walk,
build_browse_sel,
store_for_sel,
)
}
fn announce(addr: SocketAddr, lan: bool, label: &str) {
println!("Serving {label} in the browser.");
if lan {
println!(
"Listening on all interfaces, port {}. On another device open",
addr.port()
);
println!(" http://<this-machine's-IP>:{}", addr.port());
println!("warning: no authentication — anyone on your network can reach this.");
} else {
println!("Open http://127.0.0.1:{} in your browser.", addr.port());
}
println!("Press Ctrl-C to stop.");
}
fn stats(args: DeckArgs) -> Result<()> {
let scheduler = args.scheduler.scheduler();
let now = now_ms();
for path in &args.decks {
let store = store_for(std::slice::from_ref(path), args.store.clone())?;
let deck = Deck::load(path)?;
let h = histogram(&deck.cards, &store);
let mut due_now = 0usize;
let mut due_24h = 0usize;
let mut reviews = 0u32;
let mut passes = 0u32;
for card in &deck.cards {
if let Some(state) = store.get(card.id()) {
if !alix::session::is_retired(card, &store) {
let due = scheduler.due_at(state);
if due <= now {
due_now += 1;
} else if due <= now + 86_400_000 {
due_24h += 1;
}
}
reviews += state.total_reviews;
passes += state.total_passes;
}
}
let state = match deck.state(&store) {
DeckState::NotStarted => "not started",
DeckState::Started => "in progress",
DeckState::ExamDue => "exam due",
DeckState::Finished if store.deck_mastered(&deck.subject) => "mastered ✓",
DeckState::Finished => "finished ✓",
};
let top = alix::store::MAX_STAGE;
let cell = |s: usize| {
if s as u8 > top {
"–".to_string()
} else {
h[s].to_string()
}
};
println!("{} ({} cards)", deck.display_name(), deck.cards.len());
println!(" state: {state}");
println!(
" stages: new {} │ s1 {} │ s2 {} │ s3 {} │ s4 {} │ s5 {}",
h[0],
cell(1),
cell(2),
cell(3),
cell(4),
cell(5)
);
println!(" due: {due_now} now, {due_24h} within 24h");
if reviews > 0 {
println!(
" reviews: {reviews} total, {:.0}% passed",
100.0 * passes as f64 / reviews as f64
);
}
}
Ok(())
}
fn list(args: DeckArgs) -> Result<()> {
let scheduler = args.scheduler.scheduler();
let now = now_ms();
for path in &args.decks {
let store = store_for(std::slice::from_ref(path), args.store.clone())?;
let deck = Deck::load(path)?;
println!("{}", deck.display_name());
for card in &deck.cards {
let (stage, due) = match store.get(card.id()) {
Some(state) => {
let due = if alix::session::is_retired(card, &store) {
"resting".to_string()
} else {
let due = scheduler.due_at(state);
if due <= now {
"due now".to_string()
} else {
format!("due in {}", humanize_ms(due - now))
}
};
(format!("s{}", state.stage), due)
}
None => ("new".to_string(), "-".to_string()),
};
let front: String = card.front.chars().take(60).collect();
println!(" [{stage:>3}] {front:<60} {due}");
}
}
Ok(())
}
fn select_reset_ids(cards: &[Card], card: Option<&str>) -> Vec<(u64, String)> {
let by_id = card.and_then(|c| c.parse::<u64>().ok());
let needle = card.map(str::to_lowercase);
cards
.iter()
.filter(|c| match (by_id, &needle) {
(Some(id), _) => c.id() == id,
(None, Some(text)) => c.front.to_lowercase().contains(text),
(None, None) => true,
})
.map(|c| (c.id(), c.front.clone()))
.collect()
}
fn confirm(prompt: &str, yes: bool) -> Result<bool> {
if yes {
return Ok(true);
}
if !std::io::stdin().is_terminal() {
bail!("{prompt} (refusing without a terminal — pass --yes to proceed)");
}
print!("{prompt} [y/N] ");
std::io::stdout().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let answer = line.trim().to_lowercase();
Ok(answer == "y" || answer == "yes")
}
fn reset(args: ResetArgs) -> Result<()> {
let mut store = open_store(args.store.clone())?;
if args.all {
let n = store.len();
if n == 0 {
println!("No stored progress to reset.");
return Ok(());
}
if !confirm(&format!("Reset progress for all {n} card(s)?"), args.yes)? {
println!("Cancelled.");
return Ok(());
}
store.clear();
store.save()?;
println!("Reset {n} card(s).");
return Ok(());
}
let numeric_id = args.card.as_deref().and_then(|c| c.parse::<u64>().ok());
if let Some(id) = numeric_id.filter(|_| args.decks.is_empty()) {
return reset_ids(
&mut store,
vec![(id, String::new())],
format!("card {id}"),
args.card.as_deref(),
false,
args.yes,
);
}
let (deck_paths, from_deck_picker) = if args.decks.is_empty() {
if !std::io::stdout().is_terminal() {
bail!("no deck files given; try `alix reset <deck.txt>...`, `--card <id>`, or `--all`");
}
let config = Config::load(None)?;
let recent = RecentDecks::load(
recent::default_recent_path().context("cannot determine the data directory")?,
);
let decks_dir = config.decks_dir().context("cannot determine ~/decks")?;
let picked = picker::pick_to_reset(&decks_dir, &recent, &store)?;
if picked.is_empty() {
return Ok(()); }
(picked, true)
} else {
(args.decks.clone(), false)
};
let mut store = store_for(&deck_paths, args.store.clone())?;
let (cards, label, decks, _) = load_decks(&deck_paths, &HashMap::new())?;
if !args.cards && args.card.is_none() {
for subject in decks.keys() {
store.clear_deck_mastered(subject);
}
}
let (targets, from_picker): (Vec<(u64, String)>, bool) = if args.cards {
if !std::io::stdout().is_terminal() {
bail!("the card picker needs a terminal");
}
let rows: Vec<(u64, String, Option<String>)> = cards
.iter()
.filter_map(|c| {
store.get(c.id()).map(|state| {
(
c.id(),
card_label(c),
Some(format!("s{} · {}", state.stage, short_id(c.id()))),
)
})
})
.collect();
if rows.is_empty() {
println!("No stored progress to reset in {label}.");
return Ok(());
}
let chosen: std::collections::HashSet<u64> =
picker::pick_cards(rows, &format!("select cards to reset — {label}"))?
.into_iter()
.collect();
if chosen.is_empty() {
return Ok(()); }
let targets = cards
.iter()
.filter(|c| chosen.contains(&c.id()))
.map(|c| (c.id(), c.front.clone()))
.collect();
(targets, true)
} else {
(
select_reset_ids(&cards, args.card.as_deref()),
from_deck_picker,
)
};
reset_ids(
&mut store,
targets,
label,
args.card.as_deref(),
from_picker,
args.yes,
)
}
fn reset_ids(
store: &mut Store,
targets: Vec<(u64, String)>,
scope: String,
card_query: Option<&str>,
from_picker: bool,
yes: bool,
) -> Result<()> {
let present: Vec<(u64, String)> = targets
.into_iter()
.filter(|(id, _)| store.get(*id).is_some())
.collect();
if present.is_empty() {
match card_query {
Some(query) => println!("No stored progress matching {query:?}."),
None => println!("No stored progress to reset in {scope}."),
}
return Ok(());
}
let n = present.len();
if !from_picker {
let what = if card_query.is_some() {
let fronts: Vec<String> = present
.iter()
.map(|(_, f)| f.chars().take(60).collect())
.filter(|f: &String| !f.is_empty())
.collect();
if fronts.is_empty() {
scope
} else {
fronts.join("; ")
}
} else {
format!("{n} card(s) in {scope}")
};
if !confirm(&format!("Reset progress for {what}?"), yes)? {
println!("Cancelled.");
return Ok(());
}
}
for (id, _) in &present {
store.remove(*id);
}
store.save()?;
println!("Reset {n} card(s).");
Ok(())
}
fn card_label(card: &Card) -> String {
let text = if card.context.is_empty() {
card.front.clone()
} else {
card.context.join(" ")
};
text.chars().take(70).collect()
}
fn short_id(id: u64) -> String {
let s = id.to_string();
if s.len() > 9 {
format!("{}…{}", &s[..4], &s[s.len() - 4..])
} else {
s
}
}
fn browse(args: BrowseArgs) -> Result<()> {
if args.serve.serve {
return browse_serve(args);
}
if !std::io::stdout().is_terminal() {
bail!("`alix browse` needs a terminal");
}
let config = Config::load(None)?;
let mut recent = RecentDecks::load(
recent::default_recent_path().context("cannot determine the data directory")?,
);
let store = open_store(None)?;
if !args.decks.is_empty() {
let deck_store = store_for(&args.decks, None)?;
let build = build_browse(args.decks.clone(), &mut recent, Frontend::Tui)?;
let paths = subject_paths(build.decks);
return browse::run(build.cards, build.label, config.browse, paths, deck_store);
}
let mut terminal = ratatui::init();
let result = browse_loop(&mut terminal, &args, &config, &mut recent, &store);
ratatui::restore();
result
}
fn browse_loop(
terminal: &mut DefaultTerminal,
args: &BrowseArgs,
config: &Config,
recent: &mut RecentDecks,
store: &Store,
) -> Result<()> {
let mut start_in: Option<PathBuf> = None;
let mut focus: Option<PathBuf> = None;
loop {
let Some(picker::Picked {
decks: deck_paths,
workspace,
}) = pick_decks_if_empty(
Some(&mut *terminal),
args.decks.clone(),
config,
recent,
store,
false,
false,
start_in.as_deref(),
focus.as_deref(),
)?
else {
return Ok(()); };
let launched = deck_paths.first().cloned();
let deck_store = store_for(&deck_paths, None)?;
let build = build_browse(deck_paths, recent, Frontend::Tui)?;
let paths = subject_paths(build.decks);
browse::run_on(
terminal,
build.cards,
build.label,
config.browse.clone(),
paths,
deck_store,
)?;
start_in = workspace;
focus = launched;
}
}
fn browse_serve(args: BrowseArgs) -> Result<()> {
review_serve(
ReviewArgs {
decks: args.decks,
serve: args.serve,
mode: None,
scheduler: None,
order: None,
topology: None,
region: None,
new: 10,
limit: None,
cram: false,
max_typos: 2,
store: None,
config: None,
},
true,
)
}
fn augment_cmd(args: AugmentArgs) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
let deck = Deck::load(&args.deck)?;
let ask_cfg = augment::run_config(&config.ai, &config.ask);
let guidance = args.with.as_deref();
let store = store_for(std::slice::from_ref(&args.deck), args.store.clone())?;
let cache_path = augment::augment_path_for(store.path());
let mut cache = AugmentCache::open(&cache_path);
let what = match args.target {
AugmentTarget::Choices => "multiple-choice distractors",
AugmentTarget::Notes => "trivia / mnemonic notes",
AugmentTarget::Questions => "reworded question variants",
AugmentTarget::Keypoints => "answer key points",
AugmentTarget::Topology => "a topology",
AugmentTarget::Format => "card formatting",
};
let model = config
.ai
.model
.as_deref()
.or(config.ask.model.as_deref())
.unwrap_or("the default model");
eprintln!(
"Generating {what} for \"{}\" with Claude ({model}) — one batched call, \
this can take a moment…",
deck.subject
);
let (made, total, kind) = match args.target {
AugmentTarget::Choices => {
let items = warm_items(&deck.cards);
if items.is_empty() {
bail!("the deck has no cards to augment");
}
let total = items.len();
let map = augment::generate(&items, config.ai.distractor_count, guidance, &ask_cfg)?;
for (id, distractors) in &map {
cache.set_distractors(*id, distractors.clone());
}
(map.len(), total, "distractors")
}
AugmentTarget::Notes => {
let items = warm_items(&deck.cards);
if items.is_empty() {
bail!("the deck has no cards to augment");
}
let total = items.len();
let map = augment::generate_notes(&items, guidance, &ask_cfg)?;
for (id, note) in &map {
cache.set_note(*id, note.clone());
}
(map.len(), total, "notes")
}
AugmentTarget::Questions => {
let plain: Vec<Card> = deck
.cards
.iter()
.filter(|c| c.hash_lines.is_none())
.cloned()
.collect();
let items = warm_items(&plain);
if items.is_empty() {
bail!("the deck has no plain (non-cloze) cards to add question variants to");
}
let total = items.len();
let map =
augment::generate_variants(&items, config.ai.variant_count, guidance, &ask_cfg)?;
for (id, variants) in &map {
cache.set_variants(*id, variants.clone());
}
(map.len(), total, "question variants")
}
AugmentTarget::Keypoints => {
let items = warm_items(&deck.cards);
if items.is_empty() {
bail!("the deck has no cards to break into key points");
}
let total = items.len();
let map =
augment::generate_keypoints(&items, config.ai.keypoint_count, guidance, &ask_cfg)?;
for (id, keypoints) in &map {
cache.set_keypoints(*id, keypoints.clone());
}
(map.len(), total, "key points")
}
AugmentTarget::Topology => {
let items = warm_items(&deck.cards);
if items.is_empty() {
bail!("the deck has no cards to build a topology over");
}
let total = items.len();
let topo = augment::generate_topology(&items, guidance, &ask_cfg)?;
print_topology(&topo, &deck.cards);
let walked = topo.walk.len();
cache.add_topology(topo);
let deck_ids: std::collections::HashSet<u64> =
deck.cards.iter().map(|c| c.id()).collect();
let n = cache.topologies_for(&deck_ids).len();
println!(
"({n} topolog{} stored for this deck)",
if n == 1 { "y" } else { "ies" }
);
(walked, total, "a topology")
}
AugmentTarget::Format => {
let plain: Vec<Card> = deck
.cards
.iter()
.filter(|c| c.hash_lines.is_none())
.cloned()
.collect();
let items = warm_items(&plain);
if items.is_empty() {
bail!("the deck has no plain (non-cloze) cards to format");
}
let total = items.len();
let map = augment::generate_format(&items, guidance, &ask_cfg)?;
for (id, fmt) in &map {
cache.set_format(*id, fmt.clone());
}
(map.len(), total, "card formats")
}
};
cache.save()?;
println!(
"augmented {made} of {total} cards with {kind} → {}",
cache_path.display()
);
Ok(())
}
fn warm_items(cards: &[Card]) -> Vec<augment::WarmItem> {
cards.iter().map(augment::WarmItem::from_card).collect()
}
fn print_topology(topo: &augment::Topology, cards: &[Card]) {
let fronts: std::collections::HashMap<u64, String> = cards
.iter()
.map(|c| (c.id(), truncate(&one_line(&c.front), 72)))
.collect();
let unknown = "<card not in deck>".to_string();
println!(
"\ntopology '{}': {}\n({} cards walked, {} edges)\n",
topo.name,
topo.principle,
topo.walk.len(),
topo.edges.len()
);
let mut prev: Option<u64> = None;
for (i, id) in topo.walk.iter().enumerate() {
let front = fronts.get(id).unwrap_or(&unknown);
match prev {
None => println!("{:>3}. {front}", i + 1),
Some(p) => {
let why = topo
.edges
.iter()
.find(|e| e.from == p && e.to == *id)
.map(|e| e.label.as_str())
.unwrap_or("—");
println!("{:>3}. ↳ [{why}] {front}", i + 1);
}
}
prev = Some(*id);
}
println!();
}
fn one_line(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
fn deck_cmd(args: GenerateDeckArgs) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
let mut gen_cfg = config.generate.clone();
if let Some(cards) = args.cards {
gen_cfg.max_cards = cards;
}
let source = if std::path::Path::new(&args.source).exists() {
std::fs::canonicalize(&args.source)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| args.source.clone())
} else {
args.source.clone()
};
eprintln!("Generating a deck from {source} (this can take a minute)…");
let mut text = generate::generate_deck(&source, &gen_cfg, &config.ask)?;
if args.review || gen_cfg.review {
eprintln!("Reviewing the deck to remove redundant cards…");
text = generate::review_deck(&text, &gen_cfg, &config.ask)?;
}
let name = match &args.output {
Some(name) => name.clone(),
None => generate::deck_name(&source),
};
let name = if name.ends_with(".txt") {
name
} else {
format!("{name}.txt")
};
let parsed = parser::parse_str(&name, &text);
if args.print {
print!("{text}");
if !text.ends_with('\n') {
println!();
}
match &parsed {
Ok(cards) => eprintln!("({} cards — not written; --print)", cards.len()),
Err(e) => eprintln!("(warning: does not parse yet — {e})"),
}
return Ok(());
}
let dir = config
.decks_dir()
.context("cannot determine the decks directory")?;
let path = dir.join(&name);
if path.exists() && !args.force {
bail!(
"{} already exists; pass --force to overwrite",
path.display()
);
}
std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", dir.display()))?;
let body = if text.ends_with('\n') {
text
} else {
format!("{text}\n")
};
std::fs::write(&path, body).with_context(|| format!("cannot write {}", path.display()))?;
match parsed {
Ok(cards) => {
println!("Wrote {} cards to {}", cards.len(), path.display());
Ok(())
}
Err(e) => bail!(
"Saved the generated deck to {}, but it does not parse yet:\n {e}\n\
Fix that line and run `alix check {}`.",
path.display(),
path.display()
),
}
}
fn import_cmd(args: ImportArgs) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
let tsv = std::fs::read_to_string(&args.file)
.with_context(|| format!("cannot read {}", args.file.display()))?;
let text = import::tsv_to_deck(&tsv)?;
let name = match &args.output {
Some(name) => name.clone(),
None => generate::deck_name(&args.file.to_string_lossy()),
};
let name = if name.ends_with(".txt") {
name
} else {
format!("{name}.txt")
};
let parsed = parser::parse_str(&name, &text);
if args.print {
print!("{text}");
if !text.ends_with('\n') {
println!();
}
match &parsed {
Ok(cards) => eprintln!("({} cards — not written; --print)", cards.len()),
Err(e) => eprintln!("(warning: does not parse yet — {e})"),
}
return Ok(());
}
let dir = config
.decks_dir()
.context("cannot determine the decks directory")?;
let path = dir.join(&name);
if path.exists() && !args.force {
bail!(
"{} already exists; pass --force to overwrite",
path.display()
);
}
std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", dir.display()))?;
let body = if text.ends_with('\n') {
text
} else {
format!("{text}\n")
};
std::fs::write(&path, body).with_context(|| format!("cannot write {}", path.display()))?;
match parsed {
Ok(cards) => {
println!("Imported {} cards into {}", cards.len(), path.display());
Ok(())
}
Err(e) => bail!(
"Saved the deck to {}, but it does not parse yet:\n {e}\n\
Fix that line and run `alix check {}`.",
path.display(),
path.display()
),
}
}
fn exam_cmd(args: ExamArgs) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
let mut exam_cfg = config.exam.clone();
if let Some(n) = args.questions {
exam_cfg.num_questions = n;
}
let store = store_for(std::slice::from_ref(&args.deck), args.store.clone())?;
let deck = Deck::load(&args.deck)?;
if !deck.has_exam() {
bail!(
"{} declares no `% source:` — add one (a URL or a file path) to \
examine this deck",
deck.subject
);
}
if alix::deck::is_locked(&deck, config.decks_dir().as_deref(), &store) {
bail!(
"{}'s prerequisites aren't finished yet — pass their exams first, then sit this one",
deck.subject
);
}
if !std::io::stdin().is_terminal() {
bail!("`alix exam` needs a terminal");
}
let strictness = args
.strictness
.or(deck.settings.exam_strictness)
.unwrap_or(config.exam.strictness);
if deck.is_trace() {
return run_trace_exam(&deck, &config, strictness, store);
}
let decks_dir = config.decks_dir();
tui::ExamApp::new(deck, strictness, exam_cfg, config.ask, store, decks_dir).run()
}
fn run_trace_exam(
deck: &Deck,
config: &Config,
strictness: Strictness,
store: Store,
) -> Result<()> {
let trace = Trace::from_deck(deck)?;
if let Some(remaining) = alix::exam::cooldown_remaining_ms(
&store,
&deck.subject,
config.exam.retry_cooldown_secs,
now_ms(),
) {
bail!(
"this trace exam is cooling down after a failed attempt — re-walk it \
and try again in {}",
humanize_ms(remaining)
);
}
let sitting = alix::exam::Sitting::start_trace(
trace.description.clone(),
trace.compression_rubric(),
deck.subject.clone(),
deck.path.clone(),
strictness,
config.exam.clone(),
config.ask.clone(),
);
tui::ExamApp::from_sitting(sitting, store, deck.path.clone(), config.decks_dir()).run()
}
fn offer_trace_exam_capstone(deck: &Deck, config: &Config, store: Store) -> Result<()> {
if alix::deck::is_locked(deck, config.decks_dir().as_deref(), &store) {
return Ok(()); }
if alix::exam::cooldown_remaining_ms(
&store,
&deck.subject,
config.exam.retry_cooldown_secs,
now_ms(),
)
.is_some()
{
println!(
"{DIM}(the trace exam is cooling down after a recent fail — re-sit it later){RESET}"
);
return Ok(());
}
println!();
let prompt =
format!("{DIM}Take the exam to verify you can re-derive the path? [Y/n] >{RESET} ");
match read_line(&prompt)? {
Some(ans) if ans.trim().eq_ignore_ascii_case("n") => Ok(()),
None => Ok(()), _ => {
let strictness = deck
.settings
.exam_strictness
.unwrap_or(config.exam.strictness);
run_trace_exam(deck, config, strictness, store)
}
}
}
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const RESET: &str = "\x1b[0m";
fn trace_cmd(args: TraceArgs) -> Result<()> {
if args.suggest {
return trace_suggest(&args);
}
let deck = Deck::load(&args.deck)?;
if args.build {
return trace_build(&args, &deck);
}
let trace = Trace::from_deck(&deck)?;
if args.map {
return print_trace_map(&trace);
}
let scheduler = args
.scheduler
.or(deck.settings.scheduler)
.unwrap_or_default();
let store = store_for(std::slice::from_ref(&args.deck), args.store.clone())?;
let config = Config::load(args.config.as_deref())?;
if args.serve.serve {
return trace_serve(trace, scheduler, store, &args, &config);
}
if !std::io::stdin().is_terminal() {
bail!("`alix trace` needs a terminal");
}
let mut store = store;
let grade = args.grade.then_some(&config);
let completed = run_walk(trace, scheduler, &mut store, grade)?;
if completed {
return offer_trace_exam_capstone(&deck, &config, store);
}
Ok(())
}
fn trace_serve(
trace: Trace,
scheduler: SchedulerKind,
store: Store,
args: &TraceArgs,
config: &Config,
) -> Result<()> {
let addr = serve_addr(args.serve.port, args.serve.lan, config);
announce(addr, args.serve.lan, "a trace walk");
let grade = args.grade.then(|| config.ask.clone());
let walk = Walk::new(trace, scheduler);
serve::run_walk(walk, store, addr, scheduler, grade, config.keys.clone())
}
fn run_walk(
trace: Trace,
scheduler: SchedulerKind,
store: &mut Store,
grade: Option<&Config>,
) -> Result<bool> {
let mut walk = Walk::new(trace, scheduler);
let total = walk.total();
let mut last_prediction = String::new();
println!("{BOLD}Trace{RESET} {}", walk.trace().description);
if let Some(src) = &walk.trace().source {
println!("{DIM}source: {src} · {total} checkpoints{RESET}");
}
println!(
"{DIM}At each hop, put down a guess before you reveal — even a hunch beats \
\"I don't know\". The gap between your guess and the truth is the learning.{RESET}"
);
'walk: loop {
match walk.phase() {
Phase::Predict => {
let i = walk.current_index();
let checkpoint = walk
.checkpoint()
.cloned()
.expect("predict has a checkpoint");
println!("\n{BOLD}── Checkpoint {}/{} ──{RESET}", i + 1, total);
println!("{}", checkpoint.prompt);
print_givens(&checkpoint.givens);
match read_line(&format!("{DIM}predict >{RESET} "))? {
None => break 'walk, Some(text) => {
last_prediction = text.clone();
walk.predict(text);
}
}
}
Phase::Reveal => {
let checkpoint = walk.checkpoint().cloned().expect("reveal has a checkpoint");
println!("\n{BOLD}Reveal{RESET}");
match walk.trace().excerpt(&checkpoint) {
Ok(excerpt) => {
let (excerpt, _) = alix::trace::relabel_for_display(
excerpt,
checkpoint.at_origin.as_deref(),
);
print_excerpt(&excerpt);
}
Err(e) => {
let loc = checkpoint.locator.as_deref().unwrap_or("(none)");
println!("{DIM} (couldn't read the source — {e}) at: {loc}{RESET}");
}
}
if !checkpoint.points.is_empty() {
println!("{BOLD} key points{RESET}");
for point in &checkpoint.points {
println!(" • {point}");
}
}
if let Some(note) = &checkpoint.note {
println!("{DIM} ! {note}{RESET}");
}
let delta = match grade {
Some(config) => {
eprint!("{DIM} grading…{RESET}");
match alix::trace::grade_prediction(
&checkpoint,
&last_prediction,
&config.ask,
) {
Ok((delta, feedback)) => {
println!("\r{BOLD} {}{RESET} — {feedback}", delta.label());
Some(delta)
}
Err(e) => {
println!(
"\r{DIM} (grading failed: {e} — grade it yourself){RESET}"
);
read_delta()?
}
}
}
None => read_delta()?,
};
match delta {
Some(delta) => walk.grade(store, delta, now_ms()),
None => break 'walk, }
}
Phase::Done => break 'walk,
}
}
store.save().context("cannot save progress")?;
print_trace_summary(&walk);
Ok(walk.phase() == Phase::Done)
}
fn trace_build(args: &TraceArgs, deck: &Deck) -> Result<()> {
if !deck.is_trace() {
bail!(
"{} declares no `% trace:` — add the path you want to understand \
(e.g. `% trace: how X becomes Y`), then build it",
deck.subject
);
}
if deck.sources.is_empty() {
bail!(
"{} declares no `% source:` — add the scope to trace (a repo `.`, a \
directory, a file, or a URL)",
deck.subject
);
}
let config = Config::load(args.config.as_deref())?;
let source = deck.sources.first().map(String::as_str).unwrap_or_default();
eprintln!(
"Tracing a path through {source} (exploring the source — this can take a \
few minutes)…"
);
let cards = alix::trace::build(deck, &config.trace, &config.ask)?;
alix::deck::set_trace_checkpoints(&args.deck, &cards)?;
let n = parser::parse_str(&deck.subject, &cards)
.map(|c| c.len())
.unwrap_or(0);
let path = args.deck.display();
println!(
"Wrote {n} checkpoints to {path}. Review them and their `% at:` locators, \
then walk it: alix trace {path}"
);
Ok(())
}
fn trace_suggest(args: &TraceArgs) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
let source = args.deck.to_string_lossy();
eprintln!(
"Reconning {source} for traces worth tracing (one exploration pass — this \
can take a minute)…"
);
let menu = alix::trace::suggest(&source, &config.trace, &config.ask)?;
println!("{menu}");
println!(
"\n{DIM}Paste a suggestion into a new deck (its `% trace:` + `% source:`), \
then: alix trace --build <deck>{RESET}"
);
Ok(())
}
fn explore_cmd(args: ExploreArgs) -> Result<()> {
let config = Config::load(args.config.as_deref())?;
let source = args.source.to_string_lossy();
let goal = args
.goal
.as_deref()
.unwrap_or("understand the whole source");
if args.walk {
return explore_walk(&args, &config, &source, goal);
}
if args.build {
let dir = args.into.as_deref().expect("clap: --build requires --into");
eprintln!(
"Exploring {source} and filling the workspace toward \"{goal}\" (explore \
+ fill in one session — this can take a few minutes)…"
);
let (plan, filled) =
alix::explore::explore_and_fill(&source, goal, &config.trace, &config.ask)?;
println!("{plan}");
let report = alix::explore::materialize(
&plan,
dir,
goal,
args.title.as_deref(),
args.unlock_stage,
&source,
args.force,
Some(&filled),
)?;
let total = report.traces + report.decks;
let stubs = total - report.filled;
println!(
"\n{BOLD}Built {total} files{RESET} in {} — {} filled, {stubs} stub(s) \
({} traces, {} decks).",
report.dir.display(),
report.filled,
report.traces,
report.decks,
);
match alix::explore::snapshot_workspace(&report.dir) {
Ok(summary) => {
if summary.decks > 0 {
println!(
"{DIM}Froze {} excerpt(s) from {} deck(s) into {}/assets — \
the citations won't drift.{RESET}",
summary.files,
summary.decks,
report.dir.display(),
);
}
for failed in &summary.failed {
eprintln!("warning: could not freeze {failed}");
}
}
Err(e) => eprintln!("warning: could not snapshot the source: {e:#}"),
}
match args.icon.as_deref() {
Some(src) => match alix::icon::install(&report.dir, src) {
Ok(_) => println!(
"{DIM}Installed the workspace icon into {}/assets.{RESET}",
report.dir.display()
),
Err(e) => eprintln!("warning: could not install the workspace icon: {e:#}"),
},
None => match alix::icon::generate(&report.dir, &config.ask) {
Ok(_) => println!(
"{DIM}Drew a workspace icon into {}/assets.{RESET}",
report.dir.display()
),
Err(e) => eprintln!("warning: could not draw a workspace icon: {e:#}"),
},
}
println!(
"{DIM}Walk a trace: alix trace {}/<file> · review the set: \
alix review {}{RESET}",
report.dir.display(),
report.dir.display(),
);
return Ok(());
}
eprintln!(
"Exploring {source} for a learning plan toward \"{goal}\" (one pass — this \
can take a minute)…"
);
let plan = alix::explore::explore(&source, goal, &config.trace, &config.ask)?;
println!("{plan}");
if let Some(dir) = &args.into {
let report = alix::explore::materialize(
&plan,
dir,
goal,
args.title.as_deref(),
args.unlock_stage,
&source,
args.force,
None,
)?;
let total = report.traces + report.decks;
println!(
"\n{BOLD}Wrote {total} files{RESET} to {} — {} traces, {} decks, + alix.toml.",
report.dir.display(),
report.traces,
report.decks,
);
println!(
"{DIM}Build a trace: alix trace --build {}/<file> · review the set: \
alix review {}{RESET}",
report.dir.display(),
report.dir.display(),
);
} else {
println!(
"\n{DIM}Each item is a deck or trace to author next — `alix trace --build` \
a trace, write a deck by hand or with `alix generate`.{RESET}"
);
}
Ok(())
}
fn explore_walk(args: &ExploreArgs, config: &Config, source: &str, goal: &str) -> Result<()> {
if !std::io::stdin().is_terminal() {
bail!("`alix explore --walk` needs a terminal to walk");
}
eprintln!(
"Exploring {source} to build an explore walk (one pass — this can take a \
minute)…"
);
let checkpoints = alix::explore::walk(source, goal, &config.trace, &config.ask)?;
let root = std::fs::canonicalize(&args.source).unwrap_or_else(|_| args.source.clone());
let name = root.file_name().and_then(|n| n.to_str()).unwrap_or(source);
let deck_text = format!(
"% trace: exploring {name} — what it is, its parts, and its spine\n\
% source: {}\n\n{checkpoints}\n",
root.display()
);
let out = args
.output
.clone()
.unwrap_or_else(|| PathBuf::from("explore.txt"));
std::fs::write(&out, &deck_text).with_context(|| format!("cannot write {}", out.display()))?;
println!(
"{DIM}Wrote the explore walk to {} — re-walk it any time with \
`alix trace {}`.{RESET}\n",
out.display(),
out.display()
);
let deck = Deck::load(&out)?;
let trace = Trace::from_deck(&deck)?;
let scheduler = deck.settings.scheduler.unwrap_or_default();
let mut store = store_for(std::slice::from_ref(&out), None)?;
run_walk(trace, scheduler, &mut store, None).map(|_| ())
}
fn workspace_cmd(args: WorkspaceArgs) -> Result<()> {
if !std::io::stdin().is_terminal() {
bail!("`alix workspace` needs a terminal");
}
if !workspace::is_workspace(&args.dir) {
if workspace::has_decks(&args.dir) {
bail!(
"{} is a folder of decks, not a workspace — add an `alix.toml` to \
make it one, or `alix review {}` to review its decks.",
args.dir.display(),
args.dir.display(),
);
}
bail!("{} is not a workspace (no `alix.toml`)", args.dir.display());
}
loop {
let Some(picked) = picker::pick_workspace(&args.dir, true)? else {
return Ok(()); };
if picked.is_empty() {
return Ok(());
}
if let [only] = picked.as_slice() {
let deck = Deck::load(only)?;
if deck.is_trace() {
let trace = Trace::from_deck(&deck)?;
let scheduler = deck.settings.scheduler.unwrap_or_default();
let mut store = store_for(std::slice::from_ref(only), args.store.clone())?;
run_walk(trace, scheduler, &mut store, None)?;
continue;
}
}
review(ReviewArgs {
decks: picked,
mode: None,
scheduler: None,
order: None,
topology: None,
region: None,
new: 10,
limit: None,
cram: false,
max_typos: 2,
store: args.store.clone(),
config: args.config.clone(),
serve: ServeOpts {
serve: false,
port: None,
lan: false,
},
})?;
}
}
fn print_trace_map(trace: &Trace) -> Result<()> {
println!("{BOLD}Trace{RESET} {}", trace.description);
if let Some(src) = &trace.source {
println!("{DIM}source: {src}{RESET}");
}
for (i, checkpoint) in trace.checkpoints.iter().enumerate() {
println!("\n{BOLD}{}.{RESET} {}", i + 1, checkpoint.prompt);
for given in &checkpoint.givens {
println!(" {DIM}given · {given}{RESET}");
}
for point in &checkpoint.points {
println!(" • {point}");
}
if let Some(loc) = &checkpoint.locator {
println!(" {DIM}at {loc}{RESET}");
}
if let Some(note) = &checkpoint.note {
println!(" {DIM}! {note}{RESET}");
}
}
Ok(())
}
fn print_givens(givens: &[String]) {
if givens.is_empty() {
return;
}
println!("{DIM}given{RESET}");
for given in givens {
println!(" {DIM}· {given}{RESET}");
}
}
fn print_excerpt(excerpt: &alix::trace::Excerpt) {
println!("{DIM} {}{RESET}", excerpt.path.display());
for (no, text) in &excerpt.lines {
println!(" {DIM}{no:>5}{RESET} {text}");
}
if excerpt.truncated {
println!(" {DIM}… (truncated){RESET}");
}
}
fn print_trace_summary(walk: &Walk) {
let s = walk.summary();
let graded = s.passed + s.partly + s.failed;
if graded == 0 {
println!("\n{DIM}Left the trace early — no checkpoints recorded.{RESET}");
return;
}
println!(
"\n{BOLD}Walk complete{RESET} {} got it · {} partly · {} missed it",
s.passed, s.partly, s.failed
);
if s.weak.is_empty() {
println!("{DIM}Every hop landed — the path will fade gently.{RESET}");
} else {
let hops: Vec<String> = s.weak.iter().map(|i| (i + 1).to_string()).collect();
println!(
"{DIM}Weak edges (resurface sooner): checkpoint {}{RESET}",
hops.join(", ")
);
}
}
fn read_line(prompt: &str) -> Result<Option<String>> {
print!("{prompt}");
std::io::stdout().flush().ok();
let mut line = String::new();
if std::io::stdin().read_line(&mut line)? == 0 {
println!();
return Ok(None);
}
Ok(Some(line.trim_end().to_string()))
}
fn read_delta() -> Result<Option<alix::trace::Delta>> {
loop {
let prompt = format!("{DIM} gap? [n]ailed · [p]artly · [f]ailed (q to quit) >{RESET} ");
let Some(answer) = read_line(&prompt)? else {
return Ok(None);
};
match answer.trim().chars().next() {
Some('q') | Some('Q') => return Ok(None),
Some(c) => {
if let Some(delta) = alix::trace::Delta::from_key(c) {
return Ok(Some(delta));
}
}
None => {}
}
println!("{DIM} answer n, p, or f (or q to quit).{RESET}");
}
}
fn deps_cmd(deck_path: PathBuf) -> Result<()> {
if !std::io::stdout().is_terminal() {
bail!("`alix deps` needs a terminal");
}
let config = Config::load(None)?;
let decks_dir = config
.decks_dir()
.context("cannot determine the decks directory")?;
let deck = Deck::load(&deck_path)?;
let Some(selected) = picker::edit_dependencies(&decks_dir, &deck_path, &deck.requires)? else {
return Ok(()); };
let mut names: Vec<String> = Vec::new();
for path in &selected {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let name = name.to_string();
if !names.contains(&name) {
names.push(name);
}
}
}
alix::deck::set_requires(&deck_path, &names)?;
if names.is_empty() {
println!("Cleared all prerequisites of {}.", deck.subject);
} else {
println!(
"Set prerequisites of {}: {}",
deck.subject,
names.join(", ")
);
}
Ok(())
}
fn val_name<T: clap::ValueEnum>(value: T) -> String {
value
.to_possible_value()
.map(|p| p.get_name().to_string())
.unwrap_or_default()
}
fn check(decks: Vec<PathBuf>) -> Result<()> {
let mut errors = 0usize;
let mut warnings = 0usize;
for path in &decks {
if path.is_dir() && alix::workspace::is_workspace(path) {
if let Some(rel) = alix::workspace::manifest_icon(path)
&& !path.join(&rel).is_file()
{
warnings += 1;
eprintln!(
"warning: {}: `icon = \"{rel}\"` points at a missing file",
path.display()
);
}
continue;
}
match Deck::load(path) {
Err(e) => {
errors += 1;
eprintln!("error: {e}");
}
Ok(deck) => {
println!("{}: {} cards", deck.subject, deck.cards.len());
let s = &deck.settings;
let declared: Vec<String> = [
s.mode.map(|m| format!("mode: {}", val_name(m))),
s.scheduler.map(|s| format!("scheduler: {}", val_name(s))),
s.order.map(|o| format!("order: {}", val_name(o))),
s.exam_strictness
.map(|v| format!("strictness: {}", val_name(v))),
]
.into_iter()
.flatten()
.collect();
if !declared.is_empty() {
println!(" settings: {}", declared.join(", "));
}
if !deck.requires.is_empty() {
println!(" requires: {}", deck.requires.join(", "));
}
if !deck.sources.is_empty() {
println!(" sources: {}", deck.sources.join(", "));
}
if let Some(desc) = &deck.trace {
println!(" trace: {desc}");
}
for (a, b) in deck.duplicates() {
warnings += 1;
eprintln!(
"warning: {}: cards at lines {} and {} have identical answers \
and share their learning progress",
deck.subject, a.line, b.line
);
}
for card in &deck.cards {
for image in [&card.image, &card.image_back].into_iter().flatten() {
if !image.exists() {
warnings += 1;
eprintln!(
"warning: {}: card at line {} references a missing image: {}",
deck.subject,
card.line,
image.display()
);
}
}
}
for drift in alix::trace::drifted_cards(&deck) {
warnings += 1;
let what = if drift.gone {
"source file is gone"
} else {
"no longer found in the source"
};
eprintln!(
"warning: {}: card at line {} — frozen excerpt {} ({})",
deck.subject, drift.line, what, drift.at
);
}
if deck.is_trace() && !deck.cards.is_empty() {
match Trace::from_deck(&deck) {
Ok(trace) => {
for issue in trace.lint_locators() {
warnings += 1;
let line = deck.cards.get(issue.checkpoint).map_or(0, |c| c.line);
eprintln!(
"warning: {}: checkpoint at line {}: {}",
deck.subject, line, issue.message
);
}
}
Err(e) => {
warnings += 1;
eprintln!("warning: {}: {e:#}", deck.subject);
}
}
}
if !deck.is_trace() {
let base = SourceBase::for_deck(&deck);
for card in &deck.cards {
if let Some(at) = card.at.as_deref()
&& let Err(e) = base.excerpt(at)
{
warnings += 1;
eprintln!(
"warning: {}: card at line {}: `% at: {at}` — {e:#}",
deck.subject, card.line
);
}
}
}
}
}
}
if errors > 0 || warnings > 0 {
eprintln!("{errors} error(s), {warnings} warning(s)");
}
if errors > 0 {
bail!("{errors} error(s) found");
}
Ok(())
}
fn config_cmd(init: bool) -> Result<()> {
let path = config::default_config_path().context("cannot determine the config directory")?;
if init {
if path.exists() {
bail!("{} already exists; edit it directly", path.display());
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("cannot create {}", dir.display()))?;
}
std::fs::write(&path, config::default_config_toml())
.with_context(|| format!("cannot write {}", path.display()))?;
println!("wrote {}", path.display());
return Ok(());
}
if path.exists() {
println!("config file: {}", path.display());
} else {
println!(
"no config file at {} — using defaults; create one with \
`alix config --init`",
path.display()
);
}
let config = Config::load(None)?;
let keys = &config.keys;
let show = |action: &str, list: &[config::KeyPattern]| {
let keys: Vec<String> = list.iter().map(|p| p.to_string()).collect();
println!(" {action:<9} {}", keys.join(", "));
};
println!("key bindings:");
show("failed", &keys.failed);
show("partly", &keys.partly);
show("passed", &keys.passed);
show("reveal", &keys.reveal);
show("hint", &keys.hint);
show("submit", &keys.submit);
show("skip", &keys.skip);
show("remove", &keys.remove);
show("continue", &keys.cont);
show("restart", &keys.restart);
show("ask", &keys.ask);
show("save_note", &keys.save_note);
show("quit", &keys.quit);
println!("browse bindings (first/last fixed: g/G/Home/End):");
show("next", &config.browse.next);
show("prev", &config.browse.prev);
show("remove", &config.browse.remove);
show("quit", &config.browse.quit);
println!("ask:");
println!(" command {}", config.ask.command);
println!(
" model {}",
config.ask.model.as_deref().unwrap_or("(CLI default)")
);
println!(" timeout {}s", config.ask.timeout_secs);
println!(" permission {}", config.ask.permission_mode);
println!(" tools {}", config.ask.allowed_tools.join(", "));
println!("generate:");
println!(
" model {}",
config
.generate
.model
.as_deref()
.unwrap_or("(ask / CLI default)")
);
println!(" timeout {}s", config.generate.timeout_secs);
println!(" max_cards {}", config.generate.max_cards);
println!(" review {}", config.generate.review);
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
fn card(front: &str, back: &str) -> Card {
Card::plain(Arc::from("d.txt"), front.into(), vec![back.into()], None, 1)
}
#[test]
fn check_warns_on_a_missing_workspace_icon() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("alix.toml"), "icon = \"assets/gone.svg\"\n").unwrap();
std::fs::write(dir.path().join("a.txt"), "# a\n\t1\n").unwrap();
assert!(check(vec![dir.path().to_path_buf()]).is_ok());
}
#[test]
fn store_path_for_picks_workspace_else_global_else_override() {
let dir = tempfile::tempdir().unwrap();
let mk_ws = |name: &str| {
let ws = dir.path().join(name);
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"W\"\n").unwrap();
std::fs::write(ws.join("a.txt"), "# a\n\t1\n").unwrap();
std::fs::write(ws.join("b.txt"), "# b\n\t1\n").unwrap();
ws
};
let ws = mk_ws("ws");
let ws2 = mk_ws("ws2");
let ws_store = ws.join("progress.json");
let loose = dir.path().join("loose.txt");
std::fs::write(&loose, "# c\n\t1\n").unwrap();
assert_eq!(
Some(ws_store.clone()),
store_path_for(&[ws.join("a.txt")], None)
);
assert_eq!(
Some(ws_store.clone()),
store_path_for(&[ws.join("a.txt"), ws.join("b.txt")], None)
);
assert_eq!(None, store_path_for(std::slice::from_ref(&loose), None));
assert_eq!(
None,
store_path_for(&[ws.join("a.txt"), loose.clone()], None)
);
assert_eq!(
None,
store_path_for(&[ws.join("a.txt"), ws2.join("a.txt")], None)
);
assert_eq!(None, store_path_for(&[], None));
let over = dir.path().join("x.json");
assert_eq!(
Some(over.clone()),
store_path_for(&[ws.join("a.txt")], Some(&over))
);
}
#[test]
fn single_trace_to_walk_only_for_a_picked_lone_trace() {
let dir = tempfile::tempdir().unwrap();
let trace = dir.path().join("t.txt");
std::fs::write(
&trace,
"% trace: how it works\n% source: .\n\n# q\n\tpoint\n\t% at: 1\n",
)
.unwrap();
let fact = dir.path().join("f.txt");
std::fs::write(&fact, "# q\n\ta\n").unwrap();
assert!(single_trace_to_walk(true, std::slice::from_ref(&trace)).is_some());
assert!(single_trace_to_walk(false, std::slice::from_ref(&trace)).is_none());
assert!(single_trace_to_walk(true, std::slice::from_ref(&fact)).is_none());
assert!(single_trace_to_walk(true, &[trace, fact]).is_none());
}
#[test]
fn build_browse_loads_from_explicit_paths_and_filters_frontend() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("d.txt");
std::fs::write(
&path,
"% img-dir: /imgs\n# plain\n\tanswer\n# pic\n% img: a.png\n\tphoto\n",
)
.unwrap();
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
let tui = build_browse(vec![path.clone()], &mut recent, Frontend::Tui).unwrap();
assert_eq!(1, tui.cards.len());
assert_eq!("plain", tui.cards[0].front);
let web = build_browse(vec![path], &mut recent, Frontend::Web).unwrap();
assert_eq!(2, web.cards.len());
}
#[test]
fn build_browse_applies_a_cached_format_reshape() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("eng");
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"Eng\"\n").unwrap();
let path = ws.join("d.txt");
std::fs::write(&path, "# List the parts\n\tA, B, C\n").unwrap();
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
let raw = build_browse(vec![path.clone()], &mut recent, Frontend::Tui).unwrap();
let id = raw.cards[0].id();
assert_eq!(raw.cards[0].back_for_display(), ["A, B, C"]);
let store = store_for(std::slice::from_ref(&path), None).unwrap();
let mut cache = AugmentCache::open(augment::augment_path_for(store.path()));
cache.set_format(
id,
augment::Format {
front: Some("Name the parts".to_string()),
back: vec!["A".to_string(), "B".to_string(), "C".to_string()],
note: None,
mode: None,
},
);
cache.set_note(id, "the parts are well known".to_string());
cache.save().unwrap();
let merged = build_browse(vec![path], &mut recent, Frontend::Tui).unwrap();
assert_eq!(merged.cards[0].front, "Name the parts");
assert_eq!(merged.cards[0].back_for_display(), ["A", "B", "C"]);
let note = merged.cards[0].note.clone().unwrap_or_default();
assert!(note.contains("the parts are well known"), "{note}");
}
#[test]
fn build_browse_rejects_multiple_decks() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
std::fs::write(&a, "# q\n\ta\n").unwrap();
std::fs::write(&b, "# q\n\tb\n").unwrap();
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
let err = build_browse(vec![a, b], &mut recent, Frontend::Tui)
.err()
.unwrap();
assert!(format!("{err}").contains("one deck"), "{err}");
}
#[test]
fn build_browse_rejects_a_workspace_directory() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("eng");
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("m.txt"), "# q\n\ta\n").unwrap();
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
let err = build_browse(vec![ws], &mut recent, Frontend::Tui)
.err()
.unwrap();
assert!(format!("{err}").contains("workspace"), "{err}");
}
#[test]
fn reset_selects_all_without_a_filter() {
let cards = vec![card("A", "1"), card("B", "2")];
assert_eq!(2, select_reset_ids(&cards, None).len());
}
#[test]
fn expand_workspaces_member_file_inherits_workspace_settings() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().join("eng");
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("a.txt"), "# a\n\tb\n").unwrap();
std::fs::write(ws.join("alix.toml"), "[defaults]\ndirection = \"both\"\n").unwrap();
let exp = expand_workspaces(&[ws.join("a.txt")]).unwrap();
assert_eq!(1, exp.decks.len());
assert_eq!(
Some(alix::card::Direction::Both),
exp.defaults.get("a.txt").unwrap().direction
);
}
#[test]
fn reset_matches_front_substring_case_insensitively() {
let cards = vec![
card("Capital of Japan?", "Tokyo"),
card("Largest planet?", "Jupiter"),
];
let got = select_reset_ids(&cards, Some("japan"));
assert_eq!(1, got.len());
assert_eq!("Capital of Japan?", got[0].1);
}
#[test]
fn reset_matches_a_numeric_id_exactly() {
let cards = vec![card("A", "1"), card("B", "2")];
let id = cards[1].id();
assert_eq!(
vec![(id, "B".to_string())],
select_reset_ids(&cards, Some(&id.to_string()))
);
}
#[test]
fn reset_front_match_resets_all_cards_sharing_it() {
let cards = vec![
card("verb forms", "a"),
card("verb forms", "b"),
card("noun", "c"),
];
let got = select_reset_ids(&cards, Some("verb forms"));
assert_eq!(2, got.len());
assert_ne!(got[0].0, got[1].0);
}
}