From c75ba9b21f96819de3fe9e7a09d79bd36fce4282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Sat, 28 Feb 2026 12:57:02 +0100 Subject: [PATCH 1/3] feat: add initial cli definition --- src/cli.rs | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 src/cli.rs diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..085e221 --- /dev/null +++ b/src/cli.rs @@ -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, + + /// 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, + + /// filter by priority + #[arg(short, long)] + pub priority: Option, + + /// search text (substring match) + #[arg(short, long)] + pub search: Option, + + /// 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, + + /// new priority + #[arg(short, long)] + pub priority: Option, + + /// add tags + #[arg(short, long)] + pub tag: Vec, + + /// remove tags + #[arg(long)] + pub untag: Vec, +} + +#[derive(Args)] +pub struct RemoveArgs { + /// todo id + pub id: i64, +} From 1618e003833caed4951b740cf9694a3b6dbcce4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Sat, 28 Feb 2026 12:57:19 +0100 Subject: [PATCH 2/3] feat: add initial cli implementation --- Cargo.lock | 3 + Cargo.toml | 4 +- src/commands.rs | 111 +++++++++++++++++++++++++++++++ src/db.rs | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ src/display.rs | 55 +++++++++++++++ src/main.rs | 12 +++- src/model.rs | 13 ++-- 7 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 src/commands.rs create mode 100644 src/display.rs diff --git a/Cargo.lock b/Cargo.lock index 90a4466..cd12617 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,6 +161,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1534,6 +1535,8 @@ dependencies = [ "clap", "colored", "dirs", + "serde", + "serde_json", "sqlx", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 67e0016..0e91de7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,11 @@ 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"] } diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..669b06a --- /dev/null +++ b/src/commands.rs @@ -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(()) +} diff --git a/src/db.rs b/src/db.rs index d185e57..c526c93 100644 --- a/src/db.rs +++ b/src/db.rs @@ -124,6 +124,179 @@ pub async fn fetch_todo(pool: &Pool, id: i64) -> Result { 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> { + // 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 { + 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> { + 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 { + // 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 { + 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 { + 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 { diff --git a/src/display.rs b/src/display.rs new file mode 100644 index 0000000..08a2c4e --- /dev/null +++ b/src/display.rs @@ -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::>() + .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()); +} diff --git a/src/main.rs b/src/main.rs index 79a8280..7b312fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 } diff --git a/src/model.rs b/src/model.rs index 0c2fa32..c6c68b5 100644 --- a/src/model.rs +++ b/src/model.rs @@ -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, From d2635e692e7071e9c95c8883b2374a27d23d8986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Sat, 28 Feb 2026 23:04:51 +0100 Subject: [PATCH 3/3] feat: add build time completion generation --- Cargo.lock | 10 ++++++++++ Cargo.toml | 7 +++++++ build.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 build.rs diff --git a/Cargo.lock b/Cargo.lock index cd12617..5847731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,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" @@ -1533,6 +1542,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "clap_complete", "colored", "dirs", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0e91de7..3965397 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,10 @@ 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"] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..574e7a7 --- /dev/null +++ b/build.rs @@ -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(()) +}