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,