use std::{
collections::HashMap,
io::{IsTerminal, Write},
net::{Ipv4Addr, SocketAddr},
path::{Path, PathBuf},
sync::Arc,
};
use alix::{
augment::{self, AugmentCache, Topology, TopologyOrder},
card::Card,
config::{self, Config},
deck::{Deck, DeckSettings, DeckState},
depth::Depth,
generate, import, parser, preflight,
recent::{self, RecentDecks},
scheduler::{Fsrs, Scheduler},
serve,
session::{DeckInfo, Order, Session, SessionOptions},
store::{Store, VirtualCard, default_store_path},
time::{humanize_ms, now_ms},
trace::{Phase, SourceBase, Trace, Walk},
workspace,
};
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
#[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),
#[command(subcommand)]
Deck(DeckAction),
Import(ImportArgs),
Trace(TraceArgs),
Explore(ExploreArgs),
Workspace(WorkspaceArgs),
Config {
#[arg(long)]
init: bool,
},
#[command(subcommand)]
Backend(BackendAction),
}
#[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")]
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(short, long)]
yes: bool,
#[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(short, long)]
yes: bool,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Subcommand)]
enum DeckAction {
Generate(GenerateDeckArgs),
Augment(AugmentArgs),
Check {
#[arg(required = true)]
decks: Vec<PathBuf>,
},
}
#[derive(Subcommand)]
enum BackendAction {
Check {
#[arg(short, long)]
all: bool,
#[arg(long)]
config: Option<PathBuf>,
},
}
#[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 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)]
yes: bool,
#[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,
#[arg(long, requires = "serve")]
token: Option<String>,
}
#[derive(Args)]
struct DeckArgs {
#[arg(required = true)]
decks: Vec<PathBuf>,
#[arg(long)]
store: Option<PathBuf>,
}
#[derive(Args)]
struct ResetArgs {
decks: Vec<PathBuf>,
#[arg(long)]
card: Option<String>,
#[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)]
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, value_enum)]
depth: Option<Depth>,
#[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::Deck(action)) => match action {
DeckAction::Generate(args) => deck_cmd(args),
DeckAction::Augment(args) => augment_cmd(args),
DeckAction::Check { decks } => check(decks),
},
Some(Command::Import(args)) => import_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::Config { init }) => config_cmd(init),
Some(Command::Backend(action)) => match action {
BackendAction::Check { all, config } => backend_check_cmd(all, config),
},
}
}
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, 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(),
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
}
}
}
struct ReviewBuild {
session: Session,
label: String,
decks: HashMap<String, DeckInfo>,
topology_name: Option<String>,
}
const VIRTUAL_LINE_BASE: usize = 1_000_000;
fn synthesize_virtual(vc: &VirtualCard, subject: &Arc<str>, line: usize) -> Option<Card> {
let mut card = parser::parse_str(subject, &vc.text)
.ok()?
.into_iter()
.find(|c| c.id() == vc.id)?;
card.line = line;
Some(card)
}
#[expect(clippy::too_many_arguments)] fn build_review(
deck_paths: Vec<PathBuf>,
args: &ReviewArgs,
config: &Config,
store: &Store,
recent: &mut RecentDecks,
topology_sel: Option<&str>,
region_sel: Option<&str>,
depth_sel: Option<Depth>,
) -> 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 (mut 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 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 review = config
.review
.for_workspace(deck.parent().unwrap_or_else(|| Path::new("")));
let subject: Arc<str> = decks
.keys()
.next()
.map(|s| Arc::from(s.as_str()))
.unwrap_or_else(|| Arc::from(label.as_str()));
if region_sel.is_none() {
for (k, vc) in store
.virtual_cards_for(subject.as_ref())
.into_iter()
.filter(|v| !alix::session::is_retired_id(v.id, store, review.retire_after_days))
.filter(|v| !deck_ids.contains(&v.id)) .enumerate()
{
if let Some(mut card) = synthesize_virtual(vc, &subject, VIRTUAL_LINE_BASE + k) {
augment.apply_format(&mut card);
if let Some(note) = augment.note(card.id()) {
card.append_note(&[note.to_string()]);
}
cards.push(card);
}
}
}
let target_settings: Vec<&DeckSettings> = settings.iter().collect();
let order = resolve(
"order",
args.order,
target_settings.iter().map(|s| s.order),
Order::default(),
);
let depth = depth_sel
.or_else(|| store.last_depth(subject.as_ref()))
.unwrap_or_default();
let options = SessionOptions {
max_new: args.new,
limit: args.limit,
cram: args.cram,
order,
topology: topology_order,
retire_after_days: review.retire_after_days,
depth,
};
let session = Session::new(
cards,
store,
Box::new(Fsrs::new(review.retention)),
options,
now_ms(),
);
if !session.is_finished() {
recent.record(&deck_paths, now_ms());
let _ = recent.save();
}
Ok(ReviewBuild {
session,
label,
decks,
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 build_browse(deck_paths: Vec<PathBuf>, recent: &mut RecentDecks) -> 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 (mut cards, deck_label, decks, _) = load_decks(&expanded.decks, &expanded.defaults)?;
let label = deck_label;
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, 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 resolve_serve_token(cli: Option<String>, lan: bool, config: &Config) -> Result<Option<String>> {
if let Some(t) = cli
.or_else(|| config.serve.token.clone())
.filter(|t| !t.is_empty())
{
return Ok(Some(t));
}
if lan {
return Ok(Some(generate_token()?));
}
Ok(None)
}
fn generate_token() -> Result<String> {
let mut buf = [0u8; 16];
getrandom::getrandom(&mut buf)
.map_err(|e| anyhow::anyhow!("could not generate a serve pairing token: {e}"))?;
Ok(buf.iter().map(|b| format!("{b:02x}")).collect())
}
fn subject_paths(decks: HashMap<String, DeckInfo>) -> HashMap<String, PathBuf> {
decks
.into_iter()
.map(|(subject, info)| (subject, info.path))
.collect()
}
fn review(args: ReviewArgs) -> Result<()> {
review_serve(args, false)
}
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)?);
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,
args.topology.as_deref(),
args.region.as_deref(),
args.depth,
)?);
let label = b.label.clone();
(serve::Launch::Review(Box::new(b)), label)
};
let token = resolve_serve_token(args.serve.token.clone(), args.serve.lan, &config)?;
announce(addr, args.serve.lan, token.as_deref(), &label);
let opts = serve::ReviewOptions {
keys: config.keys.clone(),
picker: config.picker.clone(),
browse: config.browse.clone(),
review: config.review,
ask: config.ask.clone(),
exam: config.exam.clone(),
ai: config.ai.clone(),
auth: token,
};
let build = |paths: Vec<PathBuf>,
topology: Option<&str>,
region: Option<&str>,
depth: Option<Depth>,
store: &Store,
recent: &mut RecentDecks| {
build_review(
paths, &args, &config, store, recent, topology, region, depth,
)
.map(to_build)
};
let build_walk = |paths: &[PathBuf]| -> Result<Option<serve::WalkBuild>> {
match single_trace_to_walk(true, paths) {
Some(deck) => {
let trace = Trace::from_deck(&deck)?;
Ok(Some(serve::WalkBuild {
walk: Walk::new(trace),
}))
}
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).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, token: Option<&str>, label: &str) {
println!("Serving {label} in the browser.");
match (lan, token) {
(true, Some(t)) => {
let port = addr.port();
println!("On another device, open in a browser:");
println!(" http://<this-machine's-IP>:{port}/?token={t}");
println!("Or pair the app with: host <this-machine's-IP> port {port} token {t}");
}
(true, None) => {
println!("Listening on all interfaces, port {}.", addr.port());
println!("warning: no authentication — anyone on your network can reach this.");
}
(false, _) => println!("Open http://127.0.0.1:{} in your browser.", addr.port()),
}
println!("Press Ctrl-C to stop.");
}
fn state_label(fsrs_state: Option<u8>) -> &'static str {
match fsrs_state {
Some(1) => "learning",
Some(2) => "review",
Some(3) => "relearning",
Some(_) => "new",
None => "-",
}
}
fn stats(args: DeckArgs) -> Result<()> {
let config = Config::load(None)?;
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 review = config
.review
.for_workspace(path.parent().unwrap_or_else(|| Path::new("")));
let scheduler = Fsrs::new(review.retention);
let mut due_now = 0usize;
let mut due_24h = 0usize;
let mut due_now_reconstruct = 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, review.retire_after_days) {
let due = scheduler.due_at(state, Depth::Recall);
if due <= now {
due_now += 1;
} else if due <= now + 86_400_000 {
due_24h += 1;
}
if scheduler.is_due(state, Depth::Reconstruct, now) {
due_now_reconstruct += 1;
}
}
reviews += state.total_reviews;
passes += state.total_passes;
}
}
due_now += alix::session::count_reviewable_virtual(
&store,
&deck.subject,
&scheduler,
now,
review.retire_after_days,
);
let due_now_recall = due_now;
due_24h += alix::session::count_due_soon_virtual(
&store,
&deck.subject,
&scheduler,
now,
86_400_000,
review.retire_after_days,
);
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 ✓",
};
println!("{} ({} cards)", deck.display_name(), deck.cards.len());
println!(" state: {state}");
println!(" due: {due_now} now, {due_24h} within 24h");
println!(" due now (recall): {due_now_recall}");
println!(" due now (reconstruct): {due_now_reconstruct}");
if reviews > 0 {
println!(
" reviews: {reviews} total, {:.0}% passed",
100.0 * passes as f64 / reviews as f64
);
}
}
Ok(())
}
fn list(args: DeckArgs) -> Result<()> {
let config = Config::load(None)?;
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 review = config
.review
.for_workspace(path.parent().unwrap_or_else(|| Path::new("")));
let scheduler = Fsrs::new(review.retention);
println!("{}", deck.display_name());
for card in &deck.cards {
let (recall_label, recon_label, recognized_mark, due) = match store.get(card.id()) {
Some(state) => {
let due = if alix::session::is_retired(card, &store, review.retire_after_days) {
"resting".to_string()
} else {
let due = scheduler.due_at(state, Depth::Recall);
if due <= now {
"due now".to_string()
} else {
format!("due in {}", humanize_ms(due - now))
}
};
let recall_label = state_label(state.recall.as_ref().map(|f| f.state));
let recon_label = state_label(state.reconstruct.as_ref().map(|f| f.state));
let recognized_mark = if state.recognized_ms.is_some() {
"✓"
} else {
" "
};
(recall_label, recon_label, recognized_mark, due)
}
None => (state_label(None), state_label(None), " ", "-".to_string()),
};
let front: String = card.front.chars().take(60).collect();
println!(" [{recall_label:>10}|{recon_label:>10}]{recognized_mark} {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 is_url(source: &str) -> bool {
source.starts_with("http://") || source.starts_with("https://")
}
fn preflight_source(source: &str, threshold: u64, yes: bool) -> Result<()> {
if is_url(source) || threshold == 0 {
return Ok(());
}
let path = std::path::Path::new(source);
if !path.exists() {
return Ok(());
}
let size = preflight::tree_size(path);
if !preflight::is_oversized(size.bytes, threshold) {
return Ok(());
}
let msg = format!(
"source tree is {} files / {} — this may be a large model call",
size.files,
size.human_bytes()
);
if yes {
eprintln!("warning: {msg}; proceeding (--yes)");
return Ok(());
}
if !std::io::stdin().is_terminal() {
bail!(
"large source tree ({} files / {}); pass --yes to proceed",
size.files,
size.human_bytes()
);
}
print!("{msg}. Proceed? [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();
if answer != "y" && answer != "yes" {
bail!("aborted by user");
}
Ok(())
}
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,
);
}
if args.decks.is_empty() {
bail!("name a deck to reset, or pass `--card <id>` or `--all`");
}
let deck_paths = args.decks.clone();
let mut store = store_for(&deck_paths, args.store.clone())?;
let (cards, label, decks, _) = load_decks(&deck_paths, &HashMap::new())?;
if args.card.is_none() {
let present: Vec<(u64, String)> = select_reset_ids(&cards, None)
.into_iter()
.filter(|(id, _)| store.get(*id).is_some())
.collect();
let mastered = decks.keys().any(|subject| store.deck_mastered(subject));
let virtual_ids: Vec<u64> = decks
.keys()
.flat_map(|subject| {
store
.virtual_cards_for(subject)
.iter()
.map(|vc| vc.id)
.collect::<Vec<_>>()
})
.collect();
if present.is_empty() && !mastered && virtual_ids.is_empty() {
println!("No stored progress to reset in {label}.");
return Ok(());
}
let n = present.len();
if !confirm(
&format!("Reset progress for {n} card(s) in {label}?"),
args.yes,
)? {
println!("Cancelled.");
return Ok(());
}
for subject in decks.keys() {
store.clear_deck_mastered(subject);
}
for id in &virtual_ids {
store.remove_virtual(*id); store.remove(*id); }
for (id, _) in &present {
store.remove(*id);
}
store.save()?;
println!("Reset {n} card(s).");
return Ok(());
}
let targets = select_reset_ids(&cards, args.card.as_deref());
reset_ids(
&mut store,
targets,
label,
args.card.as_deref(),
false,
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 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 subject: Arc<str> = Arc::from(deck.subject.as_str());
let deck_ids: std::collections::HashSet<u64> =
deck.cards.iter().map(Card::id).collect();
let retire_after_days = config
.review
.for_workspace(deck.path.parent().unwrap_or_else(|| Path::new("")))
.retire_after_days;
let mut plain: Vec<Card> = deck
.cards
.iter()
.filter(|c| c.hash_lines.is_none())
.cloned()
.collect();
for (k, vc) in store
.virtual_cards_for(&deck.subject)
.into_iter()
.filter(|v| !deck_ids.contains(&v.id))
.filter(|v| !alix::session::is_retired_id(v.id, &store, retire_after_days))
.enumerate()
{
if let Some(card) = synthesize_virtual(vc, &subject, VIRTUAL_LINE_BASE + k)
&& card.hash_lines.is_none()
{
plain.push(card);
}
}
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()
};
preflight_source(&source, config.ask.preflight_threshold, args.yes)?;
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()
),
}
}
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 store = store_for(std::slice::from_ref(&args.deck), args.store.clone())?;
let config = Config::load(args.config.as_deref())?;
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, &mut store, grade)?;
if completed {
println!(
"{DIM}Walk complete — take this trace's exam in the browser: run `alix` \
and pick it.{RESET}"
);
}
Ok(())
}
fn run_walk(trace: Trace, store: &mut Store, grade: Option<&Config>) -> Result<bool> {
let mut walk = Walk::new(trace);
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();
preflight_source(source, config.ask.preflight_threshold, args.yes)?;
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();
preflight_source(&source, config.ask.preflight_threshold, args.yes)?;
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");
preflight_source(&source, config.ask.preflight_threshold, args.yes)?;
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(),
&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(),
&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 mut store = store_for(std::slice::from_ref(&out), None)?;
run_walk(trace, &mut store, None).map(|_| ())
}
fn workspace_cmd(args: WorkspaceArgs) -> Result<()> {
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());
}
review_serve(
ReviewArgs {
decks: Vec::new(),
order: None,
topology: None,
region: None,
new: 10,
limit: None,
cram: false,
depth: None,
store: args.store,
config: args.config,
serve: ServeOpts {
serve: true,
port: None,
lan: false,
token: None,
},
},
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 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.reveal.map(|r| format!("reveal: {}", val_name(r))),
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
);
}
}
}
for prereq in alix::deck::nongating_prerequisites(&deck) {
warnings += 1;
eprintln!(
"warning: {}: requires source-less `{prereq}` — this edge \
doesn't gate its exam; add a `% source:` to `{prereq}` to \
make it a real prerequisite",
deck.subject
);
}
}
}
}
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(())
}
fn backend_check_cmd(all: bool, config_path: Option<PathBuf>) -> Result<()> {
let config = Config::load(config_path.as_deref())?;
alix::backend::health::check(&config.ask, all)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use alix::answer::Mode;
use super::*;
fn card(front: &str, back: &str) -> Card {
Card::plain(Arc::from("d.txt"), front.into(), vec![back.into()], None, 1)
}
#[test]
fn serve_token_is_generated_only_when_exposed() {
let cfg = Config::default();
assert_eq!(resolve_serve_token(None, false, &cfg).unwrap(), None);
assert_eq!(
resolve_serve_token(Some("abc".into()), true, &cfg).unwrap(),
Some("abc".into())
);
assert!(
resolve_serve_token(None, true, &cfg)
.unwrap()
.is_some_and(|t| !t.is_empty())
);
}
#[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_including_image_cards() {
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 build = build_browse(vec![path], &mut recent).unwrap();
assert_eq!(2, build.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).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).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).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).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);
}
fn review_args(decks: Vec<PathBuf>, region: Option<&str>) -> ReviewArgs {
ReviewArgs {
decks,
order: None,
topology: None,
region: region.map(str::to_string),
new: 10,
limit: None,
cram: false,
depth: None,
store: None,
config: None,
serve: ServeOpts {
serve: false,
port: None,
lan: false,
token: None,
},
}
}
fn insert_virtual_card(store: &mut Store, subject: &str) {
use alix::store::VirtualKind;
let text = "# virtual front\n\tvirtual back\n".to_string();
let id = parser::parse_str(subject, &text).unwrap()[0].id();
store.insert_virtual(VirtualCard {
id,
kind: VirtualKind::Remediation,
parent: subject.to_string(),
text,
created_ms: 0,
});
store.get_or_insert(id, 0);
}
#[test]
fn build_review_injects_a_decks_virtual_cards() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rust.txt");
std::fs::write(&path, "# q1\n\ta1\n").unwrap();
let store_path = Some(dir.path().join("store.json"));
let mut store = store_for(std::slice::from_ref(&path), store_path).unwrap();
insert_virtual_card(&mut store, "rust.txt");
let config = Config::default();
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
let args = review_args(vec![], None);
let build = build_review(
vec![path],
&args,
&config,
&store,
&mut recent,
None,
None,
None,
)
.unwrap();
assert_eq!(2, build.session.initial_size);
}
#[test]
fn region_focus_excludes_virtual_cards() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rust.txt");
std::fs::write(&path, "# q1\n\ta1\n").unwrap();
let store_path = Some(dir.path().join("store.json"));
let mut store = store_for(std::slice::from_ref(&path), store_path).unwrap();
let deck = Deck::load(&path).unwrap();
let card_id = deck.cards[0].id();
let mut cache = AugmentCache::open(augment::augment_path_for(store.path()));
cache.add_topology(Topology {
name: "auto".to_string(),
principle: "test".to_string(),
edges: vec![],
walk: vec![card_id],
regions: vec![augment::TopologyRegion {
name: "r1".to_string(),
cards: vec![card_id],
}],
});
cache.save().unwrap();
insert_virtual_card(&mut store, "rust.txt");
let config = Config::default();
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
let args = review_args(vec![], Some("r1"));
let build = build_review(
vec![path],
&args,
&config,
&store,
&mut recent,
None,
Some("r1"),
None,
)
.unwrap();
assert_eq!(1, build.session.initial_size);
}
#[test]
fn a_format_cache_entry_applies_to_a_synthesized_virtual_card() {
let subject: Arc<str> = Arc::from("rust.txt");
let text = "# List the parts\n\tA, B, C\n".to_string();
let id = parser::parse_str(&subject, &text).unwrap()[0].id();
let vc = VirtualCard {
id,
kind: alix::store::VirtualKind::Remediation,
parent: subject.to_string(),
text,
created_ms: 0,
};
let mut synth = synthesize_virtual(&vc, &subject, VIRTUAL_LINE_BASE).unwrap();
let mut cache =
AugmentCache::open(std::env::temp_dir().join("nonexistent-augment-virtual.json"));
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: Some(Mode::LineByLine),
},
);
cache.apply_format(&mut synth);
assert_eq!("Name the parts", synth.front);
assert_eq!(["A", "B", "C"], *synth.back_for_display());
assert_eq!(id, synth.id(), "reshaping must not change identity");
}
#[test]
fn build_review_applies_a_cached_format_to_an_injected_virtual_card() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rust.txt");
std::fs::write(&path, "# q1\n\ta1\n").unwrap();
let store_path = Some(dir.path().join("store.json"));
let mut store = store_for(std::slice::from_ref(&path), store_path).unwrap();
insert_virtual_card(&mut store, "rust.txt");
let virtual_id =
parser::parse_str("rust.txt", "# virtual front\n\tvirtual back\n").unwrap()[0].id();
let mut cache = AugmentCache::open(augment::augment_path_for(store.path()));
cache.set_format(
virtual_id,
augment::Format {
front: Some("Reshaped virtual front".to_string()),
back: vec!["Reshaped virtual back".to_string()],
note: None,
mode: None,
},
);
cache.save().unwrap();
let config = Config::default();
let mut recent = RecentDecks::load(dir.path().join("recent.json"));
let args = review_args(vec![], None);
let build = build_review(
vec![path],
&args,
&config,
&store,
&mut recent,
None,
None,
None,
)
.unwrap();
let synth = build
.session
.cards()
.iter()
.find(|c| c.id() == virtual_id)
.expect("the injected virtual card should be in the session");
assert_eq!("Reshaped virtual front", synth.front);
assert_eq!(["Reshaped virtual back"], *synth.back_for_display());
}
}