feat: add initial cli implementation

This commit is contained in:
2026-02-28 12:57:19 +01:00
parent c75ba9b21f
commit 1618e00383
7 changed files with 359 additions and 12 deletions

3
Cargo.lock generated
View File

@@ -161,6 +161,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "windows-link",
] ]
@@ -1534,6 +1535,8 @@ dependencies = [
"clap", "clap",
"colored", "colored",
"dirs", "dirs",
"serde",
"serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
] ]

View File

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

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)) 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 // internal row type for sqlx FromRow
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct TodoRow { 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 db;
mod display;
mod model; mod model;
use clap::Parser;
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let _pool = db::connect().await?; let cli = cli::Cli::parse();
println!("database ready at {}", db::db_path()?.display()); let pool = db::connect().await?;
Ok(()) let ctx = commands::Ctx { pool: &pool };
commands::run(cli.command, &ctx).await
} }

View File

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