merge: initial cli setup

This commit is contained in:
2026-02-28 23:05:26 +01:00
9 changed files with 519 additions and 12 deletions

13
Cargo.lock generated
View File

@@ -161,6 +161,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
@@ -187,6 +188,15 @@ dependencies = [
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.5.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
@@ -1532,8 +1542,11 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"clap_complete",
"colored",
"dirs",
"serde",
"serde_json",
"sqlx",
"tokio",
]

View File

@@ -5,9 +5,18 @@ edition = "2024"
[dependencies]
anyhow = "1"
chrono = "0.4"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
colored = "3"
dirs = "6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] }
tokio = { version = "1", features = ["rt", "macros"] }
[build-dependencies]
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
serde = { version = "1", features = ["derive"] }

28
build.rs Normal file
View File

@@ -0,0 +1,28 @@
#[allow(dead_code)]
#[path = "src/model.rs"]
mod model;
include!("src/cli.rs");
use clap::{CommandFactory, ValueEnum};
use clap_complete::{generate_to, Shell};
use std::{env, io::Error};
fn main() -> Result<(), Error> {
let outdir = env::var_os("SHELL_COMPLETIONS_DIR")
.or_else(|| env::var_os("OUT_DIR"));
let Some(outdir) = outdir else {
return Ok(());
};
let mut cmd = Cli::command();
for shell in Shell::value_variants() {
let path = generate_to(*shell, &mut cmd, "todo", &outdir)?;
println!("cargo:warning=completion file is generated: {path:?}");
}
Ok(())
}

115
src/cli.rs Normal file
View File

@@ -0,0 +1,115 @@
use clap::{Args, Parser, Subcommand};
use crate::model::Priority;
#[derive(Parser)]
#[command(name = "todo", about = "global thought capture", version)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// add a new todo
#[command(alias = "a")]
Add(AddArgs),
/// list todos
#[command(alias = "ls")]
List(ListArgs),
/// mark a todo as done
Done(DoneArgs),
/// edit a todo
Edit(EditArgs),
/// remove a todo permanently
#[command(alias = "rm")]
Remove(RemoveArgs),
/// list all tags in use
Tags,
/// remove all completed todos
Purge,
}
#[derive(Args)]
pub struct AddArgs {
/// the todo text
pub text: String,
/// priority (critical/high/medium/low or c/h/m/l)
#[arg(short, long, default_value = "medium")]
pub priority: Priority,
/// tags (repeatable: -t foo -t bar)
#[arg(short, long)]
pub tag: Vec<String>,
/// do not associate with current repo
#[arg(long)]
pub no_project: bool,
}
#[derive(Args)]
pub struct ListArgs {
/// filter by tag
#[arg(short, long)]
pub tag: Vec<String>,
/// filter by priority
#[arg(short, long)]
pub priority: Option<Priority>,
/// search text (substring match)
#[arg(short, long)]
pub search: Option<String>,
/// include completed todos
#[arg(short = 'a', long)]
pub all: bool,
/// only show todos for current repo
#[arg(long)]
pub here: bool,
/// output as json
#[arg(long)]
pub json: bool,
}
#[derive(Args)]
pub struct DoneArgs {
/// todo id
pub id: i64,
}
#[derive(Args)]
pub struct EditArgs {
/// todo id
pub id: i64,
/// new text
pub text: Option<String>,
/// new priority
#[arg(short, long)]
pub priority: Option<Priority>,
/// add tags
#[arg(short, long)]
pub tag: Vec<String>,
/// remove tags
#[arg(long)]
pub untag: Vec<String>,
}
#[derive(Args)]
pub struct RemoveArgs {
/// todo id
pub id: i64,
}

111
src/commands.rs Normal file
View File

@@ -0,0 +1,111 @@
use anyhow::Result;
use colored::Colorize;
use crate::cli::{AddArgs, Command, DoneArgs, EditArgs, ListArgs, RemoveArgs};
use crate::db::{self, ListFilters, Pool};
use crate::display::{print_todo, print_todo_list};
pub struct Ctx<'a> {
pub pool: &'a Pool,
}
pub async fn run(cmd: Command, ctx: &Ctx<'_>) -> Result<()> {
match cmd {
Command::Add(args) => add(ctx, args).await,
Command::List(args) => list(ctx, args).await,
Command::Done(args) => done(ctx, args).await,
Command::Edit(args) => edit(ctx, args).await,
Command::Remove(args) => remove(ctx, args).await,
Command::Tags => tags(ctx).await,
Command::Purge => purge(ctx).await,
}
}
async fn add(ctx: &Ctx<'_>, args: AddArgs) -> Result<()> {
let project = if args.no_project {
None
} else {
db::detect_project()
};
let todo =
db::insert_todo(ctx.pool, &args.text, &args.priority, project.as_deref(), &args.tag)
.await?;
print!("{} ", "added:".green().bold());
print_todo(&todo);
Ok(())
}
async fn list(ctx: &Ctx<'_>, args: ListArgs) -> Result<()> {
let project = if args.here {
db::detect_project()
} else {
None
};
let todos = db::list_todos(
ctx.pool,
ListFilters {
tags: &args.tag,
priority: args.priority.as_ref(),
search: args.search.as_deref(),
project: project.as_deref(),
include_done: args.all,
},
)
.await?;
if args.json {
println!("{}", serde_json::to_string(&todos)?);
} else {
print_todo_list(&todos);
}
Ok(())
}
async fn done(ctx: &Ctx<'_>, args: DoneArgs) -> Result<()> {
let todo = db::complete_todo(ctx.pool, args.id).await?;
print!("{} ", "done:".green().bold());
print_todo(&todo);
Ok(())
}
async fn tags(ctx: &Ctx<'_>) -> Result<()> {
let tags = db::list_tags(ctx.pool).await?;
if tags.is_empty() {
println!("{}", "no tags".dimmed());
} else {
for tag in &tags {
println!(" {}", format!("#{tag}").blue());
}
}
Ok(())
}
async fn edit(ctx: &Ctx<'_>, args: EditArgs) -> Result<()> {
let todo = db::update_todo(
ctx.pool,
args.id,
args.text.as_deref(),
args.priority.as_ref(),
&args.tag,
&args.untag,
)
.await?;
print!("{} ", "edited:".green().bold());
print_todo(&todo);
Ok(())
}
async fn remove(ctx: &Ctx<'_>, args: RemoveArgs) -> Result<()> {
let todo = db::delete_todo(ctx.pool, args.id).await?;
print!("{} ", "removed:".red().bold());
print_todo(&todo);
Ok(())
}
async fn purge(ctx: &Ctx<'_>) -> Result<()> {
let count = db::purge_completed(ctx.pool).await?;
println!("{}", format!("purged {count} completed todo(s)").dimmed());
Ok(())
}

173
src/db.rs
View File

@@ -124,6 +124,179 @@ pub async fn fetch_todo(pool: &Pool, id: i64) -> Result<Todo> {
Ok(row.into_todo(tags))
}
// filter options for list_todos
pub struct ListFilters<'a> {
pub tags: &'a [String],
pub priority: Option<&'a Priority>,
pub search: Option<&'a str>,
pub project: Option<&'a str>,
pub include_done: bool,
}
pub async fn list_todos(pool: &Pool, filters: ListFilters<'_>) -> Result<Vec<Todo>> {
// build query dynamically based on filters
let mut sql = String::from(
"SELECT DISTINCT t.id FROM todos t LEFT JOIN todo_tags tt ON t.id = tt.todo_id WHERE 1=1",
);
let mut bind_idx = 1;
if !filters.include_done {
sql.push_str(" AND t.completed_at IS NULL");
}
if let Some(pri) = filters.priority {
sql.push_str(&format!(" AND t.priority = ?{bind_idx}"));
bind_idx += 1;
let _ = pri; // used below during bind
}
if filters.search.is_some() {
sql.push_str(&format!(" AND t.text LIKE ?{bind_idx}"));
bind_idx += 1;
}
if filters.project.is_some() {
sql.push_str(&format!(" AND t.project = ?{bind_idx}"));
bind_idx += 1;
}
for _ in filters.tags {
sql.push_str(&format!(" AND EXISTS (SELECT 1 FROM todo_tags WHERE todo_id = t.id AND tag = ?{bind_idx})"));
bind_idx += 1;
}
sql.push_str(" ORDER BY t.id");
let mut query = sqlx::query_scalar::<_, i64>(&sql);
if let Some(pri) = filters.priority {
query = query.bind(priority_to_str(pri));
}
if let Some(search) = filters.search {
query = query.bind(format!("%{search}%"));
}
if let Some(project) = filters.project {
query = query.bind(project);
}
for tag in filters.tags {
query = query.bind(tag);
}
let ids = query.fetch_all(pool).await.context("cannot list todos")?;
let mut todos = Vec::with_capacity(ids.len());
for id in ids {
todos.push(fetch_todo(pool, id).await?);
}
Ok(todos)
}
pub async fn complete_todo(pool: &Pool, id: i64) -> Result<Todo> {
let rows = sqlx::query(
"UPDATE todos SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?1 AND completed_at IS NULL",
)
.bind(id)
.execute(pool)
.await
.context("cannot complete todo")?;
anyhow::ensure!(rows.rows_affected() > 0, "todo {id} not found or already done");
fetch_todo(pool, id).await
}
pub async fn list_tags(pool: &Pool) -> Result<Vec<String>> {
let tags = sqlx::query_scalar::<_, String>(
"SELECT DISTINCT tag FROM todo_tags tt
JOIN todos t ON tt.todo_id = t.id
WHERE t.completed_at IS NULL
ORDER BY tag",
)
.fetch_all(pool)
.await
.context("cannot list tags")?;
Ok(tags)
}
pub async fn update_todo(
pool: &Pool,
id: i64,
text: Option<&str>,
priority: Option<&Priority>,
add_tags: &[String],
remove_tags: &[String],
) -> Result<Todo> {
// verify exists
let exists = sqlx::query_scalar::<_, i64>("SELECT id FROM todos WHERE id = ?1")
.bind(id)
.fetch_optional(pool)
.await
.context("cannot check todo")?;
anyhow::ensure!(exists.is_some(), "todo {id} not found");
if let Some(text) = text {
sqlx::query("UPDATE todos SET text = ?1 WHERE id = ?2")
.bind(text)
.bind(id)
.execute(pool)
.await
.context("cannot update text")?;
}
if let Some(pri) = priority {
sqlx::query("UPDATE todos SET priority = ?1 WHERE id = ?2")
.bind(priority_to_str(pri))
.bind(id)
.execute(pool)
.await
.context("cannot update priority")?;
}
for tag in add_tags {
sqlx::query("INSERT OR IGNORE INTO todo_tags (todo_id, tag) VALUES (?1, ?2)")
.bind(id)
.bind(tag)
.execute(pool)
.await
.context("cannot add tag")?;
}
for tag in remove_tags {
sqlx::query("DELETE FROM todo_tags WHERE todo_id = ?1 AND tag = ?2")
.bind(id)
.bind(tag)
.execute(pool)
.await
.context("cannot remove tag")?;
}
fetch_todo(pool, id).await
}
pub async fn delete_todo(pool: &Pool, id: i64) -> Result<Todo> {
let todo = fetch_todo(pool, id).await?;
sqlx::query("DELETE FROM todos WHERE id = ?1")
.bind(id)
.execute(pool)
.await
.context("cannot delete todo")?;
Ok(todo)
}
pub async fn purge_completed(pool: &Pool) -> Result<usize> {
let result = sqlx::query("DELETE FROM todos WHERE completed_at IS NOT NULL")
.execute(pool)
.await
.context("cannot purge completed")?;
Ok(result.rows_affected() as usize)
}
// internal row type for sqlx FromRow
#[derive(sqlx::FromRow)]
struct TodoRow {

55
src/display.rs Normal file
View File

@@ -0,0 +1,55 @@
use colored::Colorize;
use crate::model::{Priority, Todo};
pub fn print_todo(todo: &Todo) {
let id = format!("{:>3}", todo.id).dimmed();
let priority = match todo.priority {
Priority::Critical => "CRIT".red().bold(),
Priority::High => "high".red(),
Priority::Medium => " med".yellow(),
Priority::Low => " low".dimmed(),
};
let done_mark = if todo.is_done() { "[x]" } else { "[ ]" };
let text = if todo.is_done() {
todo.text.strikethrough().dimmed().to_string()
} else {
todo.text.clone()
};
let tags = if todo.tags.is_empty() {
String::new()
} else {
todo.tags
.iter()
.map(|t| format!("#{t}"))
.collect::<Vec<_>>()
.join(" ")
.blue()
.to_string()
};
let project = todo
.project_short()
.map(|p| format!("({})", p).dimmed().to_string())
.unwrap_or_default();
println!("{id} {done_mark} [{priority}] {text} {tags} {project}");
}
pub fn print_todo_list(todos: &[Todo]) {
if todos.is_empty() {
println!("{}", "no todos found".dimmed());
return;
}
for todo in todos {
print_todo(todo);
}
let count = todos.len();
println!("{}", format!("\n{count} item(s)").dimmed());
}

View File

@@ -1,9 +1,15 @@
mod cli;
mod commands;
mod db;
mod display;
mod model;
use clap::Parser;
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let _pool = db::connect().await?;
println!("database ready at {}", db::db_path()?.display());
Ok(())
let cli = cli::Cli::parse();
let pool = db::connect().await?;
let ctx = commands::Ctx { pool: &pool };
commands::run(cli.command, &ctx).await
}

View File

@@ -1,19 +1,16 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Priority {
Low,
#[default]
Medium,
High,
Critical,
}
impl Default for Priority {
fn default() -> Self {
Self::Medium
}
}
impl std::fmt::Display for Priority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -39,7 +36,7 @@ impl std::str::FromStr for Priority {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct Todo {
pub id: i64,
pub text: String,