use clap::{CommandFactory, Parser};
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{CompletionType, Config, Context, Editor, Helper};
use crate::cli::{Cli, Command};
use crate::error::Result;
use crate::model::Status;
use crate::storage;
use super::board::{pad, truncate, width};
use super::task::active_library_root;
const BUILTINS: [&str; 7] = ["help", "quit", "exit", "use", "unuse", "refresh", "q"];
const CATEGORIES: &[(&str, &[&str])] = &[
("任务管理", &["add", "list", "show", "search", "delete"]),
("状态流转", &["start", "done", "cancel", "status"]),
("内容编辑", &["set", "step", "log", "rewrite", "strike", "edit", "tag"]),
("视图统计", &["board", "stats", "remind", "overdue"]),
("批量归档", &["batch", "archive", "adopt", "fix-names"]),
("库与模板", &["lib", "template", "recur"]),
("工作台", &["use", "unuse", "refresh", "help", "quit"]),
];
const HIDDEN: [&str; 5] = ["new", "note", "exit", "q", "repl"];
const USAGE: &str = "\
用法:任意 CLI 命令加 '/' 前缀即可。按 '/' 列出全部命令,Tab 补全命令、
子命令和参数,光标后的灰字提示该命令还需要哪些参数。
/use <id> 选中任务,之后的命令可省略 id(提示符会显示 tasks[#id]>)
/unuse 取消选中
/quit 退出(也可 /exit 或 Ctrl-D)
";
pub fn run() -> Result<()> {
let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin());
let mut current: Option<u64> = None;
let mut editor = if interactive { new_editor() } else { None };
let echo = !interactive;
println!("tasks workbench — /help for commands, /quit to leave");
dashboard(current, terminal_width(editor.as_mut()))?;
loop {
let prompt = match current {
Some(seq) => format!("tasks[#{seq}]> "),
None => "tasks> ".to_string(),
};
if let Some(h) = editor.as_mut().and_then(|ed| ed.helper_mut()) {
h.task_scoped = current.is_some();
}
let line = match read_line(editor.as_mut(), &prompt, interactive) {
Input::Line(line) => line,
Input::Cancel => continue,
Input::Eof => {
println!();
return Ok(());
}
};
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
if echo {
println!("{prompt}{line}");
}
if let Some(ed) = editor.as_mut() {
let _ = ed.add_history_entry(line.as_str());
}
let Some(rest) = line.strip_prefix('/') else {
eprintln!("commands must start with '/' — try /help");
continue;
};
let words: Vec<String> = rest.split_whitespace().map(String::from).collect();
if words.is_empty() {
continue;
}
match words[0].as_str() {
"quit" | "exit" | "q" => return Ok(()),
"help" | "h" => {
print!("{}\n{USAGE}", menu("", false));
continue;
}
"refresh" => {
dashboard(current, terminal_width(editor.as_mut()))?;
continue;
}
"unuse" => {
current = None;
println!("selection cleared");
continue;
}
"use" => {
match select(words.get(1).map(String::as_str)) {
Ok(seq) => {
current = Some(seq);
if let Err(err) = super::task::show(&seq.to_string()) {
eprintln!("error: {err}");
}
}
Err(err) => eprintln!("error: {err}"),
}
continue;
}
_ => {}
}
let cli = match parse(&words, current) {
Ok(cli) => cli,
Err(rendered) => {
eprint!("{rendered}");
continue;
}
};
if matches!(cli.command, Command::Repl) {
eprintln!("error: already in the workbench");
continue;
}
let mutating = mutates(&cli.command);
if let Err(err) = crate::dispatch(cli) {
eprintln!("error: {err}");
continue;
}
if !mutating {
continue;
}
if let Some(seq) = current
&& select(Some(&seq.to_string())).is_err()
{
current = None;
}
dashboard(current, terminal_width(editor.as_mut()))?;
}
}
type Repl = Editor<ReplHelper, rustyline::history::DefaultHistory>;
fn menu(filter: &str, task_scoped: bool) -> String {
let root = Cli::command();
let mut known: Vec<String> = root
.get_subcommands()
.filter(|s| !task_scoped || scoped_to_task(s))
.flat_map(|s| {
std::iter::once(s.get_name().to_string())
.chain(s.get_all_aliases().map(String::from))
})
.chain(BUILTINS.iter().map(|s| s.to_string()))
.filter(|n| !HIDDEN.contains(&n.as_str()) && n.starts_with(filter))
.collect();
let mut out = String::new();
let mut render = |label: &str, names: &[String]| {
if names.is_empty() {
return;
}
let joined: Vec<String> = names.iter().map(|n| format!("/{n}")).collect();
let pad = 10usize.saturating_sub(label.chars().count() * 2);
out.push_str(&format!("{label}{}{}\n", " ".repeat(pad), joined.join(" ")));
};
for (label, names) in CATEGORIES {
let present: Vec<String> = names
.iter()
.filter(|n| known.iter().any(|k| k == *n))
.map(|n| n.to_string())
.collect();
known.retain(|k| !present.contains(k));
render(label, &present);
}
known.sort();
render("其他", &known);
out
}
fn new_editor() -> Option<Repl> {
let config = Config::builder()
.completion_type(CompletionType::List)
.auto_add_history(true)
.build();
let mut ed: Repl = Editor::with_config(config).ok()?;
ed.set_helper(Some(ReplHelper { task_scoped: false }));
Some(ed)
}
enum Input {
Line(String),
Cancel,
Eof,
}
fn read_line(
editor: Option<&mut Editor<ReplHelper, rustyline::history::DefaultHistory>>,
prompt: &str,
show_prompt: bool,
) -> Input {
match editor {
Some(ed) => match ed.readline(prompt) {
Ok(line) => Input::Line(line),
Err(ReadlineError::Interrupted) => Input::Cancel,
Err(ReadlineError::Eof) => Input::Eof,
Err(err) => {
eprintln!("error: {err}");
Input::Eof
}
},
None => {
use std::io::Write;
if show_prompt {
print!("{prompt}");
if std::io::stdout().flush().is_err() {
return Input::Eof;
}
}
let mut buf = String::new();
match std::io::stdin().read_line(&mut buf) {
Ok(0) | Err(_) => Input::Eof,
Ok(_) => Input::Line(buf),
}
}
}
}
fn mutates(command: &Command) -> bool {
use crate::cli::{LibCommand, RecurCommand, StepCommand, TagCommand, TemplateCommand};
match command {
Command::Repl
| Command::Info
| Command::List { .. }
| Command::Show { .. }
| Command::Search { .. }
| Command::Board { .. }
| Command::Remind { .. }
| Command::Overdue
| Command::Stats
| Command::Completions { .. } => false,
Command::Step { command } => !matches!(command, StepCommand::Get { .. }),
Command::Tag { command } => !matches!(command, TagCommand::List),
Command::Lib { command } => !matches!(command, LibCommand::List | LibCommand::Current),
Command::Template { command } => {
!matches!(command, TemplateCommand::List | TemplateCommand::Show { .. })
}
Command::Recur { command } => {
!matches!(command, RecurCommand::List | RecurCommand::Show { .. })
}
Command::Archive { dry_run, .. } | Command::Batch { dry_run, .. } => !dry_run,
Command::Adopt { dry_run, .. } => !dry_run,
Command::FixNames { dry_run } => !dry_run,
_ => true,
}
}
fn select(arg: Option<&str>) -> Result<u64> {
let id =
arg.ok_or_else(|| crate::error::Error::InvalidTaskFile("usage: /use <id>".into()))?;
let root = active_library_root()?;
Ok(storage::resolve_task(&root, id)?.task.meta.seq)
}
fn parse(words: &[String], current: Option<u64>) -> std::result::Result<Cli, String> {
let argv = |args: &[String]| {
let mut v = vec!["tasks".to_string()];
v.extend(args.iter().cloned());
v
};
let plain = || Cli::try_parse_from(argv(words)).map_err(|e| e.render().to_string());
let Some(seq) = current else { return plain() };
let root = Cli::command();
let refs: Vec<&str> = words.iter().map(String::as_str).collect();
let Some((cmd, depth)) = resolve_with_depth(&root, &refs) else { return plain() };
if !takes_task_id(cmd) || depth > words.len() {
return plain();
}
let mut with_id = words.to_vec();
with_id.insert(depth, seq.to_string());
match Cli::try_parse_from(argv(&with_id)) {
Ok(cli) => Ok(cli),
Err(_) => plain(),
}
}
fn dashboard(current: Option<u64>, cols: usize) -> Result<()> {
let root = active_library_root()?;
let tasks = storage::load_library(&root)?.tasks;
let left = in_progress_lines(&tasks, current);
let right = upcoming_lines(&tasks);
println!();
let left_w = left.iter().map(|l| width(l)).max().unwrap_or(0);
let right_w = right.iter().map(|l| width(l)).max().unwrap_or(0);
let room = cols.saturating_sub(left_w + GUTTER);
if right_w > 0 && room >= MIN_RIGHT_COL {
print_columns(&left, &right, left_w, room.min(right_w));
} else {
for line in left.iter().chain(right.iter()) {
println!("{line}");
}
}
println!("{}\n", summary(&tasks));
Ok(())
}
const GUTTER: usize = 3;
const TOP_N: usize = 5;
const DEFAULT_COLS: usize = 80;
const MIN_RIGHT_COL: usize = 24;
pub fn detected_width() -> Option<usize> {
rustyline::DefaultEditor::new()
.ok()?
.dimensions()
.map(|(cols, _)| cols as usize)
}
fn terminal_width(editor: Option<&mut Repl>) -> usize {
editor
.and_then(|ed| ed.dimensions().map(|(cols, _)| cols as usize))
.or_else(|| std::env::var("COLUMNS").ok().and_then(|c| c.parse().ok()))
.filter(|c| *c > 0)
.unwrap_or(DEFAULT_COLS)
}
fn in_progress_lines(tasks: &[storage::StoredTask], current: Option<u64>) -> Vec<String> {
let mut out = vec!["=== 进行中 ===".to_string()];
let active: Vec<_> = tasks
.iter()
.filter(|t| t.task.meta.status == Status::InProgress)
.collect();
if active.is_empty() {
out.push("(无)".to_string());
}
for st in active {
let m = &st.task.meta;
let marker = if current == Some(m.seq) { "*" } else { " " };
out.push(format!("{marker} #{} [{}] {}", m.seq, m.priority, m.title));
if let Some(d) = &m.description {
out.push(format!(" {d}"));
}
if let Some(due) = m.due_date {
out.push(format!(" due {}", due.date_naive()));
}
if !st.task.steps.is_empty() {
let done = st.task.steps.iter().filter(|s| s.done).count();
out.push(format!(" steps {done}/{}", st.task.steps.len()));
for (i, step) in st.task.steps.iter().enumerate() {
let mark = if step.done { "x" } else { " " };
out.push(format!(" s{} [{mark}] {}", i + 1, step.title));
}
}
}
out
}
fn upcoming_lines(tasks: &[storage::StoredTask]) -> Vec<String> {
let mut pending: Vec<&storage::StoredTask> = tasks
.iter()
.filter(|t| {
matches!(
t.task.meta.status,
Status::Todo | Status::Blocked | Status::InReview
)
})
.collect();
if pending.is_empty() {
return Vec::new();
}
pending.sort_by(|a, b| {
let (am, bm) = (&a.task.meta, &b.task.meta);
am.due_date
.is_none()
.cmp(&bm.due_date.is_none())
.then(am.due_date.cmp(&bm.due_date))
.then(bm.priority.cmp(&am.priority))
.then(am.seq.cmp(&bm.seq))
});
let total = pending.len();
let mut out = vec![format!("=== 待办 Top {} / {total} ===", TOP_N.min(total))];
for st in pending.iter().take(TOP_N) {
let m = &st.task.meta;
let due = m
.due_date
.map(|d| format!(" ({})", d.date_naive()))
.unwrap_or_default();
out.push(format!(" #{} [{}] {}{due}", m.seq, m.priority, m.title));
}
if total > TOP_N {
out.push(format!(" … 还有 {} 个", total - TOP_N));
}
out
}
fn print_columns(left: &[String], right: &[String], column: usize, right_w: usize) {
for i in 0..left.len().max(right.len()) {
let l = left.get(i).map(String::as_str).unwrap_or("");
match right.get(i) {
Some(r) => println!(
"{}{}{}",
pad(l, column),
" ".repeat(GUTTER),
truncate(r, right_w)
),
None => println!("{}", l.trim_end()),
}
}
}
fn summary(tasks: &[storage::StoredTask]) -> String {
let count = |s: Status| tasks.iter().filter(|t| t.task.meta.status == s).count();
let now = chrono::Utc::now();
let overdue = tasks
.iter()
.filter(|t| {
!matches!(t.task.meta.status, Status::Done | Status::Cancelled)
&& t.task.meta.due_date.is_some_and(|d| d < now)
})
.count();
format!(
"=== todo {} | blocked {} | in_review {} | done {} | cancelled {} | overdue {} ===",
count(Status::Todo),
count(Status::Blocked),
count(Status::InReview),
count(Status::Done),
count(Status::Cancelled),
overdue
)
}
struct ReplHelper {
task_scoped: bool,
}
impl Helper for ReplHelper {}
impl Validator for ReplHelper {}
impl Highlighter for ReplHelper {}
fn split_input(line: &str, pos: usize) -> (Vec<&str>, &str, usize) {
let head = &line[..pos];
let rest = head.strip_prefix('/').unwrap_or(head);
let offset = pos - rest.len();
let mut words: Vec<&str> = rest.split_whitespace().collect();
let fragment = if rest.ends_with(char::is_whitespace) {
""
} else {
words.pop().unwrap_or("")
};
let start = offset + rest.len() - fragment.len();
(words, fragment, start)
}
fn resolve_with_depth<'a>(
root: &'a clap::Command,
words: &[&str],
) -> Option<(&'a clap::Command, usize)> {
let mut cmd = root.find_subcommand(words.first()?)?;
let mut depth = 1;
if let Some(second) = words.get(1)
&& let Some(sub) = cmd.find_subcommand(second)
{
cmd = sub;
depth = 2;
}
Some((cmd, depth))
}
fn resolve<'a>(root: &'a clap::Command, words: &[&str]) -> Option<&'a clap::Command> {
resolve_with_depth(root, words).map(|(cmd, _)| cmd)
}
fn takes_task_id(cmd: &clap::Command) -> bool {
cmd.get_positionals().any(|a| a.get_id() == "id")
}
fn scoped_to_task(cmd: &clap::Command) -> bool {
takes_task_id(cmd) || cmd.get_subcommands().any(takes_task_id)
}
fn candidates(line: &str, pos: usize) -> (usize, Vec<String>) {
let (words, fragment, start) = split_input(line, pos);
let prefix = if line[..pos].starts_with('/') { "" } else { "/" };
let root = Cli::command();
let mut out: Vec<String> = Vec::new();
if words.is_empty() {
out.extend(BUILTINS.iter().map(|s| format!("{prefix}{s}")));
for sub in root.get_subcommands() {
out.push(format!("{prefix}{}", sub.get_name()));
out.extend(sub.get_all_aliases().map(|a| format!("{prefix}{a}")));
}
} else if let Some(cmd) = resolve(&root, &words) {
if words.len() == 1 {
out.extend(cmd.get_subcommands().map(|s| s.get_name().to_string()));
}
out.extend(
cmd.get_arguments()
.filter_map(|a| a.get_long())
.map(|l| format!("--{l}")),
);
}
let needle = format!("{prefix}{fragment}");
out.retain(|c| c.starts_with(&needle));
out.sort();
out.dedup();
(start, out)
}
impl Completer for ReplHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let (start, names) = candidates(line, pos);
Ok((
start,
names
.into_iter()
.map(|n| Pair { display: n.clone(), replacement: n })
.collect(),
))
}
}
impl Hinter for ReplHelper {
type Hint = String;
fn hint(&self, line: &str, pos: usize, _ctx: &Context<'_>) -> Option<String> {
if pos != line.len() || !line.starts_with('/') {
return None;
}
let (mut words, fragment, _) = split_input(line, pos);
let root = Cli::command();
if words.is_empty() {
let complete =
root.find_subcommand(fragment).is_some() || BUILTINS.contains(&fragment);
if !complete {
return Some(format!("\n{}", menu(fragment, self.task_scoped)));
}
}
if !fragment.is_empty() {
words.push(fragment);
}
let cmd = resolve(&root, &words)?;
let mut hint = String::new();
if cmd.has_subcommands() {
let subs: Vec<&str> = cmd.get_subcommands().map(|s| s.get_name()).collect();
hint.push_str(&subs.join("|"));
} else {
for arg in cmd.get_positionals() {
hint.push_str(&format!("<{}> ", arg.get_id()));
}
for arg in cmd.get_arguments() {
if !arg.is_positional()
&& arg.is_required_set()
&& let Some(long) = arg.get_long()
{
hint.push_str(&format!("--{long} "));
}
}
}
let hint = hint.trim_end().to_string();
if hint.is_empty() {
return None;
}
Some(if line.ends_with(char::is_whitespace) {
hint
} else {
format!(" {hint}")
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn names(line: &str) -> Vec<String> {
candidates(line, line.len()).1
}
#[test]
fn menu_groups_commands_by_category() {
let m = menu("", false);
for label in ["任务管理", "状态流转", "内容编辑", "视图统计", "批量归档", "库与模板", "工作台"] {
assert!(m.contains(label), "missing category {label}:\n{m}");
}
let line = |label: &str| {
m.lines().find(|l| l.starts_with(label)).unwrap_or_default().to_string()
};
assert!(line("任务管理").contains("/add"), "{m}");
assert!(line("状态流转").contains("/done"), "{m}");
assert!(line("内容编辑").contains("/log"), "{m}");
assert!(line("工作台").contains("/quit"), "{m}");
}
fn stored(seq: u64, title: &str, status: Status, pri: crate::model::Priority, due: Option<&str>) -> storage::StoredTask {
let mut task = crate::model::Task::new(seq, title);
task.set_status(status);
task.meta.priority = pri;
task.meta.due_date = due.map(|d| {
format!("{d}T00:00:00Z").parse::<chrono::DateTime<chrono::Utc>>().unwrap()
});
storage::StoredTask { path: std::path::PathBuf::from("x.md"), task }
}
#[test]
fn upcoming_orders_by_due_then_priority() {
use crate::model::Priority;
let tasks = vec![
stored(1, "无期限低", Status::Todo, Priority::Low, None),
stored(2, "晚到期", Status::Todo, Priority::Medium, Some("2026-09-01")),
stored(3, "早到期", Status::Todo, Priority::Low, Some("2026-08-01")),
stored(4, "无期限紧急", Status::Todo, Priority::Urgent, None),
stored(5, "进行中不算待办", Status::InProgress, Priority::High, None),
stored(6, "已完成不算待办", Status::Done, Priority::High, None),
];
let lines = upcoming_lines(&tasks);
let body: Vec<&String> = lines.iter().skip(1).collect();
assert!(body[0].contains("早到期"), "{lines:?}");
assert!(body[1].contains("晚到期"), "{lines:?}");
assert!(body[2].contains("无期限紧急"), "{lines:?}");
assert!(body[3].contains("无期限低"), "{lines:?}");
assert!(!lines.iter().any(|l| l.contains("进行中不算待办")), "{lines:?}");
assert!(!lines.iter().any(|l| l.contains("已完成不算待办")), "{lines:?}");
}
#[test]
fn upcoming_caps_the_list_and_reports_the_rest() {
use crate::model::Priority;
let tasks: Vec<_> = (1..=TOP_N as u64 + 3)
.map(|i| stored(i, &format!("任务{i}"), Status::Todo, Priority::Medium, None))
.collect();
let lines = upcoming_lines(&tasks);
assert!(lines[0].contains(&format!("Top {TOP_N}")), "{lines:?}");
assert_eq!(lines.len(), 1 + TOP_N + 1, "header + {TOP_N} rows + overflow");
assert!(lines.last().unwrap().contains("还有 3 个"), "{lines:?}");
}
#[test]
fn upcoming_is_empty_without_pending_tasks() {
use crate::model::Priority;
let tasks = vec![stored(1, "干完了", Status::Done, Priority::Medium, None)];
assert!(upcoming_lines(&tasks).is_empty());
}
#[test]
fn columns_align_with_cjk_titles() {
let left = vec!["=== 进行中 ===".to_string(), " #1 [medium] 重构存储层".to_string()];
let right = vec!["=== 待办 ===".to_string(), " #2 修复".to_string()];
let column = left.iter().map(|l| width(l)).max().unwrap();
for i in 0..left.len() {
let padded = pad(&left[i], column);
assert_eq!(width(&padded), column, "row {i}");
}
assert_eq!(right.len(), left.len());
}
#[test]
fn selected_task_narrows_the_menu() {
let scoped = menu("", true);
for name in ["/show", "/done", "/start", "/set", "/step", "/log", "/rewrite", "/strike", "/edit", "/tag", "/delete"] {
assert!(scoped.contains(name), "{name} missing:\n{scoped}");
}
for name in ["/list", "/add", "/search", "/board", "/stats", "/archive", "/batch", "/adopt", "/lib", "/template"] {
assert!(!scoped.contains(name), "{name} should be hidden:\n{scoped}");
}
for name in ["/unuse", "/help", "/quit", "/refresh"] {
assert!(scoped.contains(name), "{name} missing:\n{scoped}");
}
}
#[test]
fn scoped_menu_still_filters_by_prefix() {
let scoped = menu("st", true);
assert!(scoped.contains("/start") && scoped.contains("/status") && scoped.contains("/step"));
assert!(!scoped.contains("/stats"), "{scoped}");
}
#[test]
fn scoped_to_task_covers_nested_subcommands() {
let root = Cli::command();
let sub = |name: &str| root.find_subcommand(name).unwrap();
assert!(scoped_to_task(sub("step")));
assert!(scoped_to_task(sub("tag")));
assert!(scoped_to_task(sub("log")));
assert!(scoped_to_task(sub("done")));
assert!(!scoped_to_task(sub("template")));
assert!(!scoped_to_task(sub("lib")));
assert!(!scoped_to_task(sub("list")));
}
#[test]
fn every_command_appears_in_the_menu() {
let m = menu("", false);
let root = Cli::command();
for sub in root.get_subcommands() {
let name = sub.get_name();
if HIDDEN.contains(&name) {
continue;
}
assert!(
m.contains(&format!("/{name}")),
"command /{name} is not in any category (it should fall into 其他):\n{m}"
);
}
for b in BUILTINS {
if HIDDEN.contains(&b) {
continue;
}
assert!(m.contains(&format!("/{b}")), "builtin /{b} missing:\n{m}");
}
}
#[test]
fn hidden_aliases_are_not_listed() {
let m = menu("", false);
for name in ["/new", "/exit", "/repl"] {
assert!(!m.contains(name), "{name} should be hidden:\n{m}");
}
}
#[test]
fn empty_line_lists_slash_commands() {
let (start, c) = candidates("", 0);
assert_eq!(start, 0);
assert!(c.contains(&"/add".to_string()), "{c:?}");
assert!(c.contains(&"/list".to_string()), "{c:?}");
assert!(c.contains(&"/quit".to_string()), "{c:?}");
assert!(c.iter().all(|x| x.starts_with('/')), "{c:?}");
assert!(c.len() > 20, "the full command list: {}", c.len());
}
#[test]
fn after_slash_candidates_drop_the_prefix() {
let (start, c) = candidates("/", 1);
assert_eq!(start, 1);
assert!(c.contains(&"add".to_string()), "{c:?}");
assert!(c.iter().all(|x| !x.starts_with('/')), "{c:?}");
}
#[test]
fn completes_command_names() {
let c = names("/st");
assert!(c.contains(&"start".to_string()), "{c:?}");
assert!(c.contains(&"status".to_string()), "{c:?}");
assert!(c.contains(&"step".to_string()), "{c:?}");
assert!(c.contains(&"stats".to_string()), "{c:?}");
assert!(!c.contains(&"list".to_string()), "{c:?}");
}
#[test]
fn completes_builtins_and_aliases() {
assert!(names("/qu").contains(&"quit".to_string()));
assert!(names("/us").contains(&"use".to_string()));
assert!(names("/ad").contains(&"add".to_string()), "alias of new");
}
#[test]
fn completes_subcommands() {
let c = names("/step ");
assert!(c.contains(&"get".to_string()), "{c:?}");
assert!(c.contains(&"done".to_string()), "{c:?}");
let c = names("/recur t");
assert_eq!(c, vec!["tick".to_string()]);
}
#[test]
fn completes_flags() {
let c = names("/list --st");
assert_eq!(c, vec!["--status".to_string()]);
assert!(names("/new --").contains(&"--description".to_string()));
}
#[test]
fn hints_expected_arguments() {
let h = ReplHelper { task_scoped: false };
let ctx_history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&ctx_history);
assert_eq!(h.hint("/done", 5, &ctx).as_deref(), Some(" <id>"));
assert_eq!(h.hint("/step", 5, &ctx).as_deref(), Some(" get|add|done|remove"));
assert_eq!(
h.hint("/recur", 6, &ctx).as_deref(),
Some(" add|list|show|pause|resume|remove|tick")
);
assert_eq!(h.hint("/recur add", 10, &ctx).as_deref(), Some(" <title> --rule"));
assert!(h.hint("not-slash", 9, &ctx).is_none());
}
#[test]
fn hint_filters_the_menu_while_typing() {
let h = ReplHelper { task_scoped: false };
let ctx_history = rustyline::history::DefaultHistory::new();
let ctx = Context::new(&ctx_history);
let all = h.hint("/", 1, &ctx).expect("menu for '/'");
assert!(all.contains("/add") && all.contains("/quit"), "{all}");
let some = h.hint("/st", 3, &ctx).expect("menu for '/st'");
for name in ["/start", "/status", "/step", "/stats"] {
assert!(some.contains(name), "{some}");
}
assert!(!some.contains("/list"), "{some}");
let none = h.hint("/zzz", 4, &ctx).expect("menu for '/zzz'");
assert!(!none.contains('/'), "{none}");
}
#[test]
fn read_only_commands_do_not_redraw() {
let parsed = Cli::try_parse_from(["tasks", "list"]).unwrap();
assert!(!mutates(&parsed.command));
let parsed = Cli::try_parse_from(["tasks", "step", "get", "1"]).unwrap();
assert!(!mutates(&parsed.command));
let parsed = Cli::try_parse_from(["tasks", "archive", "--dry-run"]).unwrap();
assert!(!mutates(&parsed.command), "dry-run changes nothing");
}
#[test]
fn mutating_commands_redraw() {
for argv in [
vec!["tasks", "done", "1"],
vec!["tasks", "new", "t"],
vec!["tasks", "step", "add", "1", "s"],
vec!["tasks", "log", "1", "x"],
vec!["tasks", "rewrite", "1", "old", "new"],
vec!["tasks", "strike", "1", "old"],
vec!["tasks", "archive", "--force"],
] {
let parsed = Cli::try_parse_from(&argv).unwrap();
assert!(mutates(&parsed.command), "{argv:?}");
}
}
fn parsed(line: &str, current: Option<u64>) -> Cli {
let words: Vec<String> = line.split_whitespace().map(String::from).collect();
match parse(&words, current) {
Ok(cli) => cli,
Err(e) => panic!("failed to parse {line:?}: {e}"),
}
}
#[test]
fn parse_injects_selected_id() {
let words = vec!["done".to_string()];
assert!(parse(&words, None).is_err(), "no id and no selection");
match parsed("done", Some(7)).command {
Command::Done { id } => assert_eq!(id, "7"),
_ => panic!("wrong command"),
}
}
#[test]
fn parse_injects_after_nested_subcommand() {
match parsed("step done s1", Some(3)).command {
Command::Step { command: crate::cli::StepCommand::Done { id, step } } => {
assert_eq!(id, "3");
assert_eq!(step, "s1");
}
_ => panic!("wrong command"),
}
}
#[test]
fn parse_injects_when_the_plain_form_would_also_parse() {
match parsed("log 一些正文", Some(5)).command {
Command::Log { id, text, .. } => {
assert_eq!(id, "5");
assert_eq!(text, vec!["一些正文".to_string()]);
}
_ => panic!("wrong command"),
}
match parsed("rewrite 旧 新", Some(5)).command {
Command::Rewrite { id, old, new, .. } => {
assert_eq!(id, "5");
assert_eq!(old, "旧");
assert_eq!(new, vec!["新".to_string()]);
}
_ => panic!("wrong command"),
}
match parsed("edit", Some(5)).command {
Command::Edit { id, .. } => assert_eq!(id.as_deref(), Some("5")),
_ => panic!("wrong command"),
}
}
#[test]
fn explicit_id_beats_the_selection() {
match parsed("show 9", Some(1)).command {
Command::Show { id } => assert_eq!(id, "9"),
_ => panic!("wrong command"),
}
match parsed("step get 9", Some(1)).command {
Command::Step { command: crate::cli::StepCommand::Get { id } } => {
assert_eq!(id, "9")
}
_ => panic!("wrong command"),
}
}
#[test]
fn commands_without_a_task_id_are_untouched() {
match parsed("list --status todo", Some(1)).command {
Command::List { filter, .. } => assert_eq!(filter.status.as_deref(), Some("todo")),
_ => panic!("wrong command"),
}
match parsed("add 新任务", Some(1)).command {
Command::New { title, .. } => assert_eq!(title, "新任务"),
_ => panic!("wrong command"),
}
match parsed("search 关键词", Some(1)).command {
Command::Search { query, .. } => assert_eq!(query, "关键词"),
_ => panic!("wrong command"),
}
match parsed("adopt", Some(1)).command {
Command::Adopt { paths, .. } => assert!(paths.is_empty(), "{paths:?}"),
_ => panic!("wrong command"),
}
match parsed("template show bug", Some(1)).command {
Command::Template { command: crate::cli::TemplateCommand::Show { name } } => {
assert_eq!(name, "bug")
}
_ => panic!("wrong command"),
}
match parsed("tag list", Some(1)).command {
Command::Tag { command: crate::cli::TagCommand::List } => {}
_ => panic!("wrong command"),
}
}
#[test]
fn takes_task_id_is_derived_from_clap() {
let root = Cli::command();
let has = |line: &str| {
let words: Vec<&str> = line.split_whitespace().collect();
takes_task_id(resolve(&root, &words).unwrap())
};
assert!(has("done"));
assert!(has("edit"));
assert!(has("log"));
assert!(has("strike"));
assert!(has("tag add"));
assert!(!has("list"));
assert!(!has("adopt"));
assert!(!has("new"));
assert!(!has("template show"));
}
#[test]
fn parse_errors_are_clap_rendered() {
let err = match parse(&["nonsense".to_string()], None) {
Err(e) => e,
Ok(_) => panic!("expected a parse error"),
};
assert!(err.contains("unrecognized subcommand"), "{err}");
assert!(!err.contains("invalid task file"), "no bogus wrapper: {err}");
}
}