use std::{
fmt,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use clap::ValueEnum;
use serde::Deserialize;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Key {
Char(char),
Enter,
Tab,
Esc,
Backspace,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KeyPattern {
pub key: Key,
pub ctrl: bool,
}
impl KeyPattern {
pub fn is_plain_char(&self) -> bool {
matches!(self.key, Key::Char(_)) && !self.ctrl
}
}
impl fmt::Display for KeyPattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.ctrl {
write!(f, "Ctrl-")?;
}
match self.key {
Key::Char(' ') => write!(f, "SPACE"),
Key::Char(c) if self.ctrl => write!(f, "{}", c.to_uppercase()),
Key::Char(c) => write!(f, "{c}"),
Key::Enter => write!(f, "ENTER"),
Key::Tab => write!(f, "TAB"),
Key::Esc => write!(f, "ESC"),
Key::Backspace => write!(f, "BACKSPACE"),
}
}
}
pub fn parse_key(s: &str) -> Result<KeyPattern> {
let lower = s.trim().to_lowercase();
let (ctrl, name) = match lower.strip_prefix("ctrl-") {
Some(rest) => (true, rest),
None => (false, lower.as_str()),
};
let key = match name {
"space" => Key::Char(' '),
"enter" | "return" => Key::Enter,
"tab" => Key::Tab,
"esc" | "escape" => Key::Esc,
"backspace" => Key::Backspace,
_ => {
let mut chars = name.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => Key::Char(c),
_ => bail!(
"invalid key {s:?}: expected a single character, \
space/enter/tab/esc/backspace, or a ctrl- prefix"
),
}
}
};
Ok(KeyPattern { key, ctrl })
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Bindings {
pub failed: Vec<KeyPattern>,
pub partly: Vec<KeyPattern>,
pub passed: Vec<KeyPattern>,
pub reveal: Vec<KeyPattern>,
pub hint: Vec<KeyPattern>,
pub submit: Vec<KeyPattern>,
pub skip: Vec<KeyPattern>,
pub remove: Vec<KeyPattern>,
pub cont: Vec<KeyPattern>,
pub restart: Vec<KeyPattern>,
pub ask: Vec<KeyPattern>,
pub save_note: Vec<KeyPattern>,
pub quit: Vec<KeyPattern>,
}
impl Default for Bindings {
fn default() -> Self {
let keys = |list: &[&str]| list.iter().map(|s| parse_key(s).unwrap()).collect();
Self {
failed: keys(&["1", "f"]),
partly: keys(&["2", "p"]),
passed: keys(&["3", "n"]),
reveal: keys(&["space", "enter"]),
hint: keys(&["tab", "ctrl-h", "ctrl-backspace"]),
submit: keys(&["enter"]),
skip: keys(&["ctrl-s"]),
remove: keys(&["ctrl-x"]),
cont: keys(&["enter", "space"]),
restart: keys(&["r"]),
ask: keys(&["?"]),
save_note: keys(&["ctrl-n"]),
quit: keys(&["esc", "ctrl-c"]),
}
}
}
impl Bindings {
pub fn label(list: &[KeyPattern]) -> String {
list.first()
.map(|p| p.to_string())
.unwrap_or_else(|| "?".to_string())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PickerKeys {
pub up: Vec<KeyPattern>,
pub down: Vec<KeyPattern>,
pub open: Vec<KeyPattern>,
pub back: Vec<KeyPattern>,
pub filter: Vec<KeyPattern>,
pub mastered: Vec<KeyPattern>,
}
impl Default for PickerKeys {
fn default() -> Self {
let keys = |list: &[&str]| list.iter().map(|s| parse_key(s).unwrap()).collect();
Self {
up: keys(&["k"]),
down: keys(&["j"]),
open: keys(&["l"]),
back: keys(&["h"]),
filter: keys(&["/", "ctrl-f"]),
mastered: keys(&["m"]),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BrowseBindings {
pub next: Vec<KeyPattern>,
pub prev: Vec<KeyPattern>,
pub remove: Vec<KeyPattern>,
pub quit: Vec<KeyPattern>,
}
impl Default for BrowseBindings {
fn default() -> Self {
let keys = |list: &[&str]| list.iter().map(|s| parse_key(s).unwrap()).collect();
Self {
next: keys(&["l", "n", "space"]),
prev: keys(&["h", "p"]),
remove: keys(&["x"]),
quit: keys(&["q", "esc", "ctrl-c"]),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AskConfig {
pub command: String,
pub model: Option<String>,
pub effort: Option<String>,
pub timeout_secs: u64,
pub permission_mode: String,
pub allowed_tools: Vec<String>,
pub cwd: Option<PathBuf>,
pub source_access: bool,
}
impl Default for AskConfig {
fn default() -> Self {
Self {
command: "claude".to_string(),
model: None,
effort: None,
timeout_secs: 120,
permission_mode: "dontAsk".to_string(),
allowed_tools: vec!["WebFetch".to_string(), "WebSearch".to_string()],
cwd: None,
source_access: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GenerateDeckConfig {
pub model: Option<String>,
pub timeout_secs: u64,
pub max_cards: usize,
pub extra: Option<String>,
pub prompt: Option<String>,
pub review: bool,
}
impl Default for GenerateDeckConfig {
fn default() -> Self {
Self {
model: None,
timeout_secs: 300,
max_cards: 30,
extra: None,
prompt: None,
review: false,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, ValueEnum)]
pub enum Strictness {
Strict,
#[default]
Balanced,
Lenient,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ExamConfig {
pub model: Option<String>,
pub timeout_secs: u64,
pub num_questions: usize,
pub pass_threshold: f64,
pub strictness: Strictness,
pub retry_cooldown_secs: u64,
pub extra: Option<String>,
}
impl Default for ExamConfig {
fn default() -> Self {
Self {
model: None,
timeout_secs: 300,
num_questions: 5,
pass_threshold: 1.0,
strictness: Strictness::default(),
retry_cooldown_secs: 3600,
extra: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TraceConfig {
pub model: Option<String>,
pub effort: Option<String>,
pub timeout_secs: u64,
pub extra: Option<String>,
}
impl Default for TraceConfig {
fn default() -> Self {
Self {
model: Some("opus".to_string()),
effort: Some("high".to_string()),
timeout_secs: 600,
extra: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AiConfig {
pub model: Option<String>,
pub distractor_count: usize,
pub variant_count: usize,
pub keypoint_count: usize,
pub timeout_secs: u64,
}
impl Default for AiConfig {
fn default() -> Self {
Self {
model: None,
distractor_count: 3,
variant_count: 4,
keypoint_count: 5,
timeout_secs: 300,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServeConfig {
pub port: u16,
}
impl Default for ServeConfig {
fn default() -> Self {
Self { port: 7777 }
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Config {
pub keys: Bindings,
pub picker: PickerKeys,
pub browse: BrowseBindings,
pub ask: AskConfig,
pub generate: GenerateDeckConfig,
pub exam: ExamConfig,
pub trace: TraceConfig,
pub ai: AiConfig,
pub serve: ServeConfig,
pub decks_dir: Option<PathBuf>,
}
impl Config {
pub fn decks_dir(&self) -> Option<PathBuf> {
self.decks_dir.clone().or_else(default_decks_dir)
}
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(default)]
keys: RawKeys,
#[serde(default)]
picker: RawPicker,
#[serde(default)]
browse: RawBrowse,
#[serde(default)]
ask: RawAsk,
#[serde(default)]
generate: RawGenerate,
#[serde(default)]
exam: RawExam,
#[serde(default)]
trace: RawTrace,
#[serde(default)]
ai: RawAi,
#[serde(default)]
serve: RawServe,
decks_dir: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawAi {
model: Option<String>,
distractor_count: Option<usize>,
variant_count: Option<usize>,
keypoint_count: Option<usize>,
timeout_secs: Option<u64>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawServe {
port: Option<u16>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawGenerate {
model: Option<String>,
timeout_secs: Option<u64>,
max_cards: Option<usize>,
extra: Option<String>,
prompt: Option<String>,
review: Option<bool>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawExam {
model: Option<String>,
timeout_secs: Option<u64>,
num_questions: Option<usize>,
pass_threshold: Option<f64>,
strictness: Option<String>,
retry_cooldown_secs: Option<u64>,
extra: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawTrace {
model: Option<String>,
effort: Option<String>,
timeout_secs: Option<u64>,
extra: Option<String>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawPicker {
up: Option<Vec<String>>,
down: Option<Vec<String>>,
open: Option<Vec<String>>,
back: Option<Vec<String>>,
filter: Option<Vec<String>>,
mastered: Option<Vec<String>>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawBrowse {
next: Option<Vec<String>>,
prev: Option<Vec<String>>,
remove: Option<Vec<String>>,
quit: Option<Vec<String>>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawAsk {
command: Option<String>,
model: Option<String>,
effort: Option<String>,
timeout_secs: Option<u64>,
permission_mode: Option<String>,
allowed_tools: Option<Vec<String>>,
source_access: Option<bool>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawKeys {
failed: Option<Vec<String>>,
partly: Option<Vec<String>>,
passed: Option<Vec<String>>,
reveal: Option<Vec<String>>,
hint: Option<Vec<String>>,
submit: Option<Vec<String>>,
skip: Option<Vec<String>>,
remove: Option<Vec<String>>,
r#continue: Option<Vec<String>>,
restart: Option<Vec<String>>,
ask: Option<Vec<String>>,
save_note: Option<Vec<String>>,
quit: Option<Vec<String>>,
}
impl Config {
pub fn from_toml(text: &str) -> Result<Self> {
let raw: RawConfig = toml::from_str(text).context("invalid config file")?;
let mut keys = Bindings::default();
let assign = |target: &mut Vec<KeyPattern>,
source: Option<Vec<String>>,
action: &str|
-> Result<()> {
if let Some(list) = source {
*target = list
.iter()
.map(|s| parse_key(s).with_context(|| format!("in binding for {action:?}")))
.collect::<Result<_>>()?;
}
Ok(())
};
assign(&mut keys.failed, raw.keys.failed, "failed")?;
assign(&mut keys.partly, raw.keys.partly, "partly")?;
assign(&mut keys.passed, raw.keys.passed, "passed")?;
assign(&mut keys.reveal, raw.keys.reveal, "reveal")?;
assign(&mut keys.hint, raw.keys.hint, "hint")?;
assign(&mut keys.submit, raw.keys.submit, "submit")?;
assign(&mut keys.skip, raw.keys.skip, "skip")?;
assign(&mut keys.remove, raw.keys.remove, "remove")?;
assign(&mut keys.cont, raw.keys.r#continue, "continue")?;
assign(&mut keys.restart, raw.keys.restart, "restart")?;
assign(&mut keys.ask, raw.keys.ask, "ask")?;
assign(&mut keys.save_note, raw.keys.save_note, "save_note")?;
assign(&mut keys.quit, raw.keys.quit, "quit")?;
let mut picker = PickerKeys::default();
assign(&mut picker.up, raw.picker.up, "picker.up")?;
assign(&mut picker.down, raw.picker.down, "picker.down")?;
assign(&mut picker.open, raw.picker.open, "picker.open")?;
assign(&mut picker.back, raw.picker.back, "picker.back")?;
assign(&mut picker.filter, raw.picker.filter, "picker.filter")?;
assign(&mut picker.mastered, raw.picker.mastered, "picker.mastered")?;
let mut browse = BrowseBindings::default();
assign(&mut browse.next, raw.browse.next, "browse.next")?;
assign(&mut browse.prev, raw.browse.prev, "browse.prev")?;
assign(&mut browse.remove, raw.browse.remove, "browse.remove")?;
assign(&mut browse.quit, raw.browse.quit, "browse.quit")?;
let mut ask = AskConfig::default();
if let Some(command) = raw.ask.command {
ask.command = command;
}
if let Some(model) = raw.ask.model.filter(|m| !m.trim().is_empty()) {
ask.model = Some(model);
}
if let Some(effort) = raw.ask.effort.filter(|e| !e.trim().is_empty()) {
ask.effort = Some(effort);
}
if let Some(secs) = raw.ask.timeout_secs {
ask.timeout_secs = secs;
}
if let Some(mode) = raw.ask.permission_mode {
ask.permission_mode = mode;
}
if let Some(tools) = raw.ask.allowed_tools {
ask.allowed_tools = tools;
}
if let Some(source_access) = raw.ask.source_access {
ask.source_access = source_access;
}
let mut generate = GenerateDeckConfig::default();
if let Some(model) = raw.generate.model.filter(|m| !m.trim().is_empty()) {
generate.model = Some(model);
}
if let Some(secs) = raw.generate.timeout_secs {
generate.timeout_secs = secs;
}
if let Some(max) = raw.generate.max_cards {
generate.max_cards = max;
}
generate.extra = raw.generate.extra.filter(|s| !s.trim().is_empty());
generate.prompt = raw.generate.prompt.filter(|s| !s.trim().is_empty());
if let Some(review) = raw.generate.review {
generate.review = review;
}
let mut exam = ExamConfig::default();
if let Some(model) = raw.exam.model.filter(|m| !m.trim().is_empty()) {
exam.model = Some(model);
}
if let Some(secs) = raw.exam.timeout_secs {
exam.timeout_secs = secs;
}
if let Some(n) = raw.exam.num_questions {
exam.num_questions = n;
}
if let Some(t) = raw.exam.pass_threshold {
exam.pass_threshold = t;
}
if let Some(secs) = raw.exam.retry_cooldown_secs {
exam.retry_cooldown_secs = secs;
}
if let Some(s) = raw.exam.strictness.filter(|s| !s.trim().is_empty()) {
match Strictness::from_str(s.trim(), true) {
Ok(v) => exam.strictness = v,
Err(_) => {
bail!("invalid exam.strictness {s:?}: expected strict, balanced, or lenient")
}
}
}
exam.extra = raw.exam.extra.filter(|s| !s.trim().is_empty());
let mut trace = TraceConfig::default();
if let Some(model) = raw.trace.model.filter(|m| !m.trim().is_empty()) {
trace.model = Some(model);
}
if let Some(effort) = raw.trace.effort.filter(|e| !e.trim().is_empty()) {
trace.effort = Some(effort);
}
if let Some(secs) = raw.trace.timeout_secs {
trace.timeout_secs = secs;
}
trace.extra = raw.trace.extra.filter(|s| !s.trim().is_empty());
let mut ai = AiConfig::default();
if let Some(model) = raw.ai.model.filter(|m| !m.trim().is_empty()) {
ai.model = Some(model);
}
if let Some(count) = raw.ai.distractor_count {
ai.distractor_count = count;
}
if let Some(count) = raw.ai.variant_count {
ai.variant_count = count;
}
if let Some(count) = raw.ai.keypoint_count {
ai.keypoint_count = count;
}
if let Some(secs) = raw.ai.timeout_secs {
ai.timeout_secs = secs;
}
let mut serve = ServeConfig::default();
if let Some(port) = raw.serve.port {
serve.port = port;
}
let decks_dir = raw.decks_dir.map(|s| expand_tilde(&s));
Ok(Self {
keys,
picker,
browse,
ask,
generate,
exam,
trace,
ai,
serve,
decks_dir,
})
}
pub fn load(path: Option<&Path>) -> Result<Self> {
let path = match path {
Some(path) => path.to_path_buf(),
None => match default_config_path() {
Some(path) if path.exists() => path,
_ => return Ok(Self::default()),
},
};
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read config file {}", path.display()))?;
Self::from_toml(&text).with_context(|| format!("in config file {}", path.display()))
}
}
pub fn default_config_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "alix").map(|dirs| dirs.config_dir().join("config.toml"))
}
pub fn default_decks_dir() -> Option<PathBuf> {
directories::BaseDirs::new().map(|dirs| dirs.home_dir().join("decks"))
}
fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/")
&& let Some(dirs) = directories::BaseDirs::new()
{
return dirs.home_dir().join(rest);
}
PathBuf::from(path)
}
pub fn default_config_toml() -> &'static str {
r#"# alix configuration.
#
# Every option below is shown commented out at its default value, as a
# reference. Uncomment a line and edit it to override that default; lines you
# leave commented keep the built-in default, so improvements to the defaults
# in newer versions still reach you. Keep the section headers ([keys], [picker],
# [browse], [ask], [generate], [exam], [trace], [ai], [serve]) so an uncommented
# line lands in the right section.
#
# Keys are written as a single character ("j"), a special key name
# ("space", "enter", "tab", "esc", "backspace"), or either with a "ctrl-"
# prefix ("ctrl-s"). The first key of each list is shown in the footer.
#
# Note: while you are typing an answer (typing and fuzzy mode), plain
# character bindings are ignored so they cannot shadow text input; use
# ctrl-/special keys for hint, skip and quit.
# Directory the startup picker lists decks from (when `alix` is launched
# without deck arguments). A leading ~ is expanded. Defaults to ~/decks.
# decks_dir = "~/decks"
# Review key bindings (flip / typing / fuzzy / choice modes).
[keys]
# failed = ["1", "f"] # self-graded: grade as failed (reset)
# partly = ["2", "p"] # self-graded: grade as partly (demote one stage)
# passed = ["3", "n"] # self-graded: grade as passed (advance)
# reveal = ["space", "enter"] # flip mode: show the answer
# hint = ["tab", "ctrl-h", "ctrl-backspace"] # typing mode (fails the card)
# submit = ["enter"] # fuzzy mode: submit the current line
# skip = ["ctrl-s"] # requeue the current card without grading
# remove = ["ctrl-x"] # mark the card for removal from the deck file
# continue = ["enter", "space"] # leave the feedback screen
# restart = ["r"] # start a new session from the summary screen
# ask = ["?"] # ask Claude about an answered card
# save_note = ["ctrl-n"] # ask view: save a condensed note to the deck
# quit = ["esc", "ctrl-c"] # quit the session
# Navigation keys for the deck picker (Vim-style by default). The arrow keys,
# Enter (open) and Esc (back) always work regardless of these; jumping to the
# first/last row is fixed at g / G / Home / End (like [browse]).
[picker]
# up = ["k"] # move up
# down = ["j"] # move down
# open = ["l"] # open the focused row
# back = ["h"] # step back / cancel
# filter = ["/", "ctrl-f"] # start filtering
# mastered = ["m"] # open the Mastered window
# Key bindings for `alix browse` (the read-only reader). Jumping to the first
# and last card is fixed to g / G / Home / End, and the arrow keys always
# move next/previous; these three are configurable:
[browse]
# next = ["l", "n", "space"] # next card
# prev = ["h", "p"] # previous card
# remove = ["x"] # mark the card for removal from the deck file
# quit = ["q", "esc", "ctrl-c"] # leave the browser
# Settings for the ask-Claude integration. Questions are sent to the
# command below (the Claude Code CLI) together with the card as context.
[ask]
# command = "claude" # executable to run
# model = "" # --model override; empty = the CLI's default
# effort = "" # --effort: low|medium|high|xhigh|max; empty = CLI default
# timeout_secs = 120 # give up waiting after this many seconds
# Permission mode for the headless CLI. "dontAsk" silently denies any tool
# not listed below — no interactive prompt (which would hang -p mode).
# Other values: "bypassPermissions" (allow everything; unsafe), "default"
# (prompts, so it hangs headless). Empty omits the flag.
# permission_mode = "dontAsk"
# Tools the assistant may use. With "dontAsk" this is an exclusive
# allowlist; the defaults let it consult deck links but nothing else.
# allowed_tools = ["WebFetch", "WebSearch"]
# Let the tutor READ the card's source (Read/Glob/Grep at the deck's % source:
# project root) to verify its answer instead of relying on memory. Off by
# default: it grants the (possibly LAN-served) tutor file-read access — only
# enable on a machine and network you trust.
# source_access = false
# AI deck generation (`alix deck <source>`). Reuses the [ask] command,
# permission mode and tool allowlist (WebFetch reads the page).
[generate]
# model = "" # --model override; empty = use [ask] / CLI default
# timeout_secs = 300 # generation is slower than a single question
# max_cards = 30 # upper bound on cards per deck
# extra = "" # extra guidance appended to the prompt
# prompt = "" # full prompt override; may use {url} and {max_cards}
# review = false # run a second pass to drop redundant cards (--review)
# AI exam (`alix exam <deck>`). Generates open understanding questions from
# the deck's `% source:` and grades typed answers; passing marks the deck
# "mastered" and unlocks its dependents. Reuses the [ask] command, permission
# mode and tool allowlist (WebFetch reads a source URL).
[exam]
# model = "" # --model override; empty = use [ask] / CLI default
# timeout_secs = 300 # each exam call is slower than a single question
# num_questions = 5 # questions asked per sitting
# pass_threshold = 1.0 # fraction of questions that must fully pass (1.0 = all)
# strictness = "balanced" # answer grading: strict | balanced | lenient
# retry_cooldown_secs = 3600 # wait this long before re-sitting a FAILED trace exam (0 = off)
# extra = "" # extra guidance appended to question generation
# Trace building (`alix trace --build <deck>`). Explores the deck's `% source:`
# to discover the path and writes the checkpoints back. Reuses the [ask] command,
# but runs with read-only file tools (Read/Glob/Grep, + WebFetch for a URL
# source) and the source root as the working directory — never a write/shell tool.
[trace]
# model = "opus" # default (correctness-critical); empty = use [ask] / CLI default
# effort = "high" # default; --effort: low|medium|high|xhigh|max
# timeout_secs = 600 # exploring a source is the slowest call
# extra = "" # extra guidance appended to the build prompt
# AI deck augmentation (`alix deck augment <deck>`). Generates choice-mode
# distractors (and notes) into a sidecar cache beside your progress; review
# reads them. Reuses the [ask] command; generation is a tool-free text call.
[ai]
# model = "" # --model override; empty = use [ask] / CLI default
# distractor_count = 3 # wrong options generated per choice card
# variant_count = 4 # reworded question variants generated per card
# keypoint_count = 5 # max key points per card (explain-mode checklist)
# timeout_secs = 300 # a whole-deck batch is a big call; wait this long
# Local web frontend (`alix serve`). Binds to localhost by default; `--lan`
# exposes it to the network and `--port` overrides the port set here.
[serve]
# port = 7777 # default port for `alix serve`
"#
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_single_chars() {
assert_eq!(
KeyPattern {
key: Key::Char('j'),
ctrl: false
},
parse_key("j").unwrap()
);
assert_eq!(
KeyPattern {
key: Key::Char('1'),
ctrl: false
},
parse_key("1").unwrap()
);
}
#[test]
fn parse_special_keys() {
assert_eq!(Key::Char(' '), parse_key("space").unwrap().key);
assert_eq!(Key::Enter, parse_key("enter").unwrap().key);
assert_eq!(Key::Enter, parse_key("return").unwrap().key);
assert_eq!(Key::Tab, parse_key("tab").unwrap().key);
assert_eq!(Key::Esc, parse_key("ESC").unwrap().key);
assert_eq!(Key::Backspace, parse_key("backspace").unwrap().key);
}
#[test]
fn parse_ctrl_combinations() {
assert_eq!(
KeyPattern {
key: Key::Char('s'),
ctrl: true
},
parse_key("ctrl-s").unwrap()
);
assert_eq!(
KeyPattern {
key: Key::Backspace,
ctrl: true
},
parse_key("ctrl-backspace").unwrap()
);
}
#[test]
fn parse_rejects_garbage() {
assert!(parse_key("jj").is_err());
assert!(parse_key("").is_err());
assert!(parse_key("ctrl-").is_err());
assert!(parse_key("super-x").is_err());
}
#[test]
fn display_labels() {
assert_eq!("j", parse_key("j").unwrap().to_string());
assert_eq!("SPACE", parse_key("space").unwrap().to_string());
assert_eq!("Ctrl-S", parse_key("ctrl-s").unwrap().to_string().as_str());
assert_eq!("ESC", parse_key("esc").unwrap().to_string());
}
#[test]
fn defaults_are_consistent_and_an_empty_file_loads() {
assert_eq!(Bindings::default(), Config::default().keys);
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
std::fs::write(&cfg, "").unwrap();
assert!(Config::load(Some(&cfg)).is_ok());
}
#[test]
fn rebind_grades_to_jkl() {
let config =
Config::from_toml("[keys]\nfailed = [\"j\"]\npartly = [\"k\"]\npassed = [\"l\"]\n")
.unwrap();
assert_eq!(vec![parse_key("j").unwrap()], config.keys.failed);
assert_eq!(vec![parse_key("k").unwrap()], config.keys.partly);
assert_eq!(vec![parse_key("l").unwrap()], config.keys.passed);
assert_eq!(Bindings::default().quit, config.keys.quit);
}
#[test]
fn continue_is_a_valid_table_key() {
let config = Config::from_toml("[keys]\ncontinue = [\"ctrl-n\"]\n").unwrap();
assert_eq!(vec![parse_key("ctrl-n").unwrap()], config.keys.cont);
}
#[test]
fn picker_keys_override_and_default() {
let config = Config::from_toml("[picker]\ndown = [\"n\"]\nopen = [\"o\"]\n").unwrap();
assert_eq!(vec![parse_key("n").unwrap()], config.picker.down);
assert_eq!(vec![parse_key("o").unwrap()], config.picker.open);
assert_eq!(PickerKeys::default().up, config.picker.up);
assert_eq!(PickerKeys::default().mastered, config.picker.mastered);
}
#[test]
fn unknown_action_is_rejected() {
assert!(Config::from_toml("[keys]\nfrobnicate = [\"x\"]\n").is_err());
}
#[test]
fn unknown_section_is_rejected() {
assert!(Config::from_toml("[keyz]\nagain = [\"x\"]\n").is_err());
}
#[test]
fn bad_key_in_binding_is_rejected() {
let err = Config::from_toml("[keys]\nfailed = [\"jj\"]\n").unwrap_err();
assert!(format!("{err:#}").contains("failed"));
}
#[test]
fn template_parses_to_defaults() {
let config = Config::from_toml(default_config_toml()).unwrap();
assert_eq!(Config::default(), config);
}
#[test]
fn template_commented_values_equal_defaults() {
let uncommented = default_config_toml()
.lines()
.map(|line| match line.trim_start().strip_prefix("# ") {
Some(rest) if is_setting_line(rest) => rest,
_ => line,
})
.collect::<Vec<_>>()
.join("\n");
let config = Config::from_toml(&uncommented).unwrap();
assert_eq!(Config::default(), config);
}
fn is_setting_line(s: &str) -> bool {
let Some((key, _)) = s.split_once('=') else {
return false;
};
let key = key.trim();
!key.is_empty()
&& key != "decks_dir"
&& key.chars().all(|c| c.is_ascii_lowercase() || c == '_')
}
#[test]
fn ask_section_overrides_defaults() {
let config = Config::from_toml(
"[ask]\ncommand = \"my-claude\"\nmodel = \"haiku\"\ntimeout_secs = 30\n",
)
.unwrap();
assert_eq!("my-claude", config.ask.command);
assert_eq!(Some("haiku".to_string()), config.ask.model);
assert_eq!(30, config.ask.timeout_secs);
assert_eq!("dontAsk", config.ask.permission_mode);
assert_eq!(vec!["WebFetch", "WebSearch"], config.ask.allowed_tools);
}
#[test]
fn trace_defaults_to_a_strong_model_and_high_effort() {
let trace = Config::default().trace;
assert_eq!(Some("opus".to_string()), trace.model);
assert_eq!(Some("high".to_string()), trace.effort);
}
#[test]
fn trace_section_overrides_model_and_effort() {
let config = Config::from_toml("[trace]\nmodel = \"sonnet\"\neffort = \"max\"\n").unwrap();
assert_eq!(Some("sonnet".to_string()), config.trace.model);
assert_eq!(Some("max".to_string()), config.trace.effort);
}
#[test]
fn ask_effort_is_off_by_default_and_overridable() {
assert_eq!(None, Config::default().ask.effort);
let config = Config::from_toml("[ask]\neffort = \"medium\"\n").unwrap();
assert_eq!(Some("medium".to_string()), config.ask.effort);
}
#[test]
fn ai_defaults_to_three_distractors_and_cli_model() {
let ai = Config::default().ai;
assert_eq!(3, ai.distractor_count);
assert_eq!(None, ai.model);
}
#[test]
fn ai_section_overrides_defaults() {
let config = Config::from_toml("[ai]\ndistractor_count = 5\nmodel = \"haiku\"\n").unwrap();
assert_eq!(5, config.ai.distractor_count);
assert_eq!(Some("haiku".to_string()), config.ai.model);
}
#[test]
fn ai_empty_model_means_cli_default() {
let config = Config::from_toml("[ai]\nmodel = \"\"\n").unwrap();
assert_eq!(None, config.ai.model);
}
#[test]
fn unknown_ai_setting_is_rejected() {
assert!(Config::from_toml("[ai]\nbogus = 1\n").is_err());
}
#[test]
fn decks_dir_defaults_to_none_and_expands_tilde() {
assert_eq!(None, Config::default().decks_dir);
let config = Config::from_toml("decks_dir = \"~/my-decks\"\n").unwrap();
let dir = config.decks_dir.unwrap();
assert!(dir.ends_with("my-decks"));
assert!(!dir.to_string_lossy().contains('~'));
}
#[test]
fn ask_permission_and_tools_are_configurable() {
let config = Config::from_toml(
"[ask]\npermission_mode = \"bypassPermissions\"\n\
allowed_tools = [\"WebFetch\", \"Read\"]\n",
)
.unwrap();
assert_eq!("bypassPermissions", config.ask.permission_mode);
assert_eq!(vec!["WebFetch", "Read"], config.ask.allowed_tools);
}
#[test]
fn empty_model_means_cli_default() {
let config = Config::from_toml("[ask]\nmodel = \"\"\n").unwrap();
assert_eq!(None, config.ask.model);
}
#[test]
fn unknown_ask_setting_is_rejected() {
assert!(Config::from_toml("[ask]\ntemperature = 1.0\n").is_err());
}
#[test]
fn exam_strictness_defaults_to_balanced_and_parses() {
assert_eq!(Strictness::Balanced, Config::default().exam.strictness);
let config = Config::from_toml("[exam]\nstrictness = \"strict\"\n").unwrap();
assert_eq!(Strictness::Strict, config.exam.strictness);
let config = Config::from_toml("[exam]\nstrictness = \"LENIENT\"\n").unwrap();
assert_eq!(Strictness::Lenient, config.exam.strictness);
assert_eq!(5, config.exam.num_questions);
}
#[test]
fn invalid_exam_strictness_is_rejected() {
let err = Config::from_toml("[exam]\nstrictness = \"harsh\"\n").unwrap_err();
assert!(format!("{err:#}").contains("strictness"));
}
#[test]
fn browse_keys_default_to_vim_and_are_rebindable() {
let defaults = BrowseBindings::default();
assert_eq!(parse_key("l").unwrap(), defaults.next[0]);
assert_eq!(parse_key("h").unwrap(), defaults.prev[0]);
let config = Config::from_toml("[browse]\nnext = [\"j\"]\nprev = [\"k\"]\n").unwrap();
assert_eq!(vec![parse_key("j").unwrap()], config.browse.next);
assert_eq!(vec![parse_key("k").unwrap()], config.browse.prev);
assert_eq!(defaults.quit, config.browse.quit);
}
#[test]
fn unknown_browse_setting_is_rejected() {
assert!(Config::from_toml("[browse]\nfirst = [\"g\"]\n").is_err());
}
}