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::{
Cmd, CompletionType, Config, ConditionalEventHandler, Context, Editor, Event, EventContext,
EventHandler, Helper, KeyEvent, RepeatCount,
};
use crate::cli::{Cli, Command};
use crate::error::Result;
use crate::model::Status;
use crate::storage;
use super::task::active_library_root;
const NESTED: [&str; 4] = ["step", "body", "tag", "template"];
const BUILTINS: [&str; 7] = ["help", "quit", "exit", "use", "unuse", "refresh", "q"];
const HELP: &str = "\
Any CLI command works, prefixed with '/'. Tab completes commands, subcommands
and flags; the greyed-out text after the cursor shows the expected arguments.
Frequently used:
/add <title> create a task (alias of /new)
/list [filters] e.g. /list --status todo --tag backend
/show [id] details /step get [id] TODO list
/start /done /cancel [id] /step add [id] <title>
/set [id] --title ... /step done [id] <sN>
/body append [id] <text> /edit [id] open $EDITOR
Workbench:
/use <id> select a task; later commands may omit the id
/unuse clear the selection
/refresh redraw the dashboard
/help this help
/quit leave (also /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)?;
loop {
let prompt = match current {
Some(seq) => format!("tasks[#{seq}]> "),
None => "tasks> ".to_string(),
};
let line = match read_line(editor.as_mut(), &prompt, interactive) {
Some(line) => line,
None => {
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!("{HELP}");
continue;
}
"refresh" => {
dashboard(current)?;
continue;
}
"unuse" => {
current = None;
println!("selection cleared");
continue;
}
"use" => {
match select(words.get(1).map(String::as_str)) {
Ok((seq, title)) => {
current = Some(seq);
println!("selected #{seq} {title}");
}
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)?;
}
}
type Repl = Editor<ReplHelper, rustyline::history::DefaultHistory>;
struct SlashMenu;
impl ConditionalEventHandler for SlashMenu {
fn handle(&self, _: &Event, _: RepeatCount, _: bool, ctx: &EventContext) -> Option<Cmd> {
if !ctx.line().is_empty() {
return None;
}
print_menu();
Some(Cmd::SelfInsert(1, '/'))
}
}
fn print_menu() {
use std::io::Write;
let (_, names) = candidates("", 0);
const WIDTH: usize = 78;
const COL: usize = 18;
let mut out = String::from("\n");
let mut line = String::new();
for name in names {
if line.len() + COL > WIDTH {
out.push_str(line.trim_end());
out.push('\n');
line.clear();
}
line.push_str(&format!("{name:<COL$}"));
}
if !line.trim_end().is_empty() {
out.push_str(line.trim_end());
out.push('\n');
}
print!("{out}");
std::io::stdout().flush().ok();
}
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));
ed.bind_sequence(
KeyEvent::from('/'),
EventHandler::Conditional(Box::new(SlashMenu)),
);
Some(ed)
}
fn read_line(
editor: Option<&mut Editor<ReplHelper, rustyline::history::DefaultHistory>>,
prompt: &str,
show_prompt: bool,
) -> Option<String> {
match editor {
Some(ed) => match ed.readline(prompt) {
Ok(line) => Some(line),
Err(ReadlineError::Interrupted | ReadlineError::Eof) => None,
Err(err) => {
eprintln!("error: {err}");
None
}
},
None => {
use std::io::Write;
if show_prompt {
print!("{prompt}");
std::io::stdout().flush().ok()?;
}
let mut buf = String::new();
match std::io::stdin().read_line(&mut buf) {
Ok(0) | Err(_) => None,
Ok(_) => Some(buf),
}
}
}
}
fn mutates(command: &Command) -> bool {
use crate::cli::{BodyCommand, LibCommand, 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::Body { command } => match command {
BodyCommand::Append { .. } | BodyCommand::Replace { .. } | BodyCommand::Delete { .. } => true,
},
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, String)> {
let id =
arg.ok_or_else(|| crate::error::Error::InvalidTaskFile("usage: /use <id>".into()))?;
let root = active_library_root()?;
let st = storage::resolve_task(&root, id)?;
Ok((st.task.meta.seq, st.task.meta.title))
}
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
};
match Cli::try_parse_from(argv(words)) {
Ok(cli) => Ok(cli),
Err(first) => {
let Some(seq) = current else {
return Err(first.render().to_string());
};
let at = if NESTED.contains(&words[0].as_str()) { 2 } else { 1 };
if at > words.len() {
return Err(first.render().to_string());
}
let mut with_id = words.to_vec();
with_id.insert(at, seq.to_string());
Cli::try_parse_from(argv(&with_id)).map_err(|_| first.render().to_string())
}
}
}
fn dashboard(current: Option<u64>) -> Result<()> {
let root = active_library_root()?;
let tasks = storage::load_library(&root)?.tasks;
println!("\n=== in progress ===");
let active: Vec<_> = tasks
.iter()
.filter(|t| t.task.meta.status == Status::InProgress)
.collect();
if active.is_empty() {
println!("(none)");
}
for st in &active {
let m = &st.task.meta;
let marker = if current == Some(m.seq) { "*" } else { " " };
println!("{marker} #{} [{}] {}", m.seq, m.priority, m.title);
if let Some(d) = &m.description {
println!(" {d}");
}
if let Some(due) = m.due_date {
println!(" due {}", due.date_naive());
}
if !st.task.steps.is_empty() {
let done = st.task.steps.iter().filter(|s| s.done).count();
println!(" steps {done}/{}", st.task.steps.len());
for (i, step) in st.task.steps.iter().enumerate() {
let mark = if step.done { "x" } else { " " };
println!(" s{} [{mark}] {}", i + 1, step.title);
}
}
}
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();
println!(
"=== todo {} | blocked {} | in_review {} | done {} | cancelled {} | overdue {} ===\n",
count(Status::Todo),
count(Status::Blocked),
count(Status::InReview),
count(Status::Done),
count(Status::Cancelled),
overdue
);
Ok(())
}
struct ReplHelper;
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<'a>(root: &'a clap::Command, words: &[&str]) -> Option<&'a clap::Command> {
let mut cmd = root.find_subcommand(words.first()?)?;
if let Some(second) = words.get(1)
&& let Some(sub) = cmd.find_subcommand(second)
{
cmd = sub;
}
Some(cmd)
}
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);
if !fragment.is_empty() {
words.push(fragment);
}
let root = Cli::command();
let cmd = resolve(&root, &words)?;
if !line.ends_with(char::is_whitespace) && cmd.get_name() != *words.last()? {
return None;
}
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()));
}
}
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 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("/body a");
assert_eq!(c, vec!["append".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;
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!(h.hint("not-slash", 9, &ctx).is_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", "body", "append", "1", "x"],
vec!["tasks", "archive", "--force"],
] {
let parsed = Cli::try_parse_from(&argv).unwrap();
assert!(mutates(&parsed.command), "{argv:?}");
}
}
#[test]
fn parse_injects_selected_id() {
let words = vec!["done".to_string()];
assert!(parse(&words, None).is_err(), "no id and no selection");
let cli = parse(&words, Some(7)).unwrap();
match cli.command {
Command::Done { id } => assert_eq!(id, "7"),
_ => panic!("wrong command"),
}
}
#[test]
fn parse_injects_after_nested_subcommand() {
let words = vec!["step".to_string(), "done".to_string(), "s1".to_string()];
let cli = parse(&words, Some(3)).unwrap();
match cli.command {
Command::Step { command: crate::cli::StepCommand::Done { id, step } } => {
assert_eq!(id, "3");
assert_eq!(step, "s1");
}
_ => panic!("wrong command"),
}
}
#[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}");
}
}