From 8ea1765f33ba20a08927a426b094ea5e90dc8459 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 18 Mar 2026 19:09:26 -0600 Subject: [PATCH] move task model types from db.rs into model.rs --- src/cmd/create.rs | 5 +- src/cmd/doctor.rs | 40 +++--- src/cmd/done.rs | 3 +- src/cmd/import.rs | 13 +- src/cmd/list.rs | 17 ++- src/cmd/mod.rs | 13 +- src/cmd/next.rs | 15 +-- src/cmd/ready.rs | 7 +- src/cmd/reopen.rs | 3 +- src/cmd/rm.rs | 3 +- src/cmd/search.rs | 3 +- src/cmd/show.rs | 6 +- src/cmd/stats.rs | 13 +- src/cmd/update.rs | 7 +- src/cmd/webui/handlers.rs | 63 ++++------ src/cmd/webui/helpers.rs | 11 +- src/cmd/webui/mutations.rs | 19 +-- src/color.rs | 50 ++++---- src/db.rs | 251 +------------------------------------ src/lib.rs | 1 + src/model.rs | 233 ++++++++++++++++++++++++++++++++++ src/ops.rs | 55 ++++---- tests/cli_sync.rs | 26 ++-- 23 files changed, 419 insertions(+), 438 deletions(-) create mode 100644 src/model.rs diff --git a/src/cmd/create.rs b/src/cmd/create.rs index 64a662f687c3326d45f37b0771cb276b5a746a6f..32fd55be62faa55c03ad173d066a6dea84293a8d 100644 --- a/src/cmd/create.rs +++ b/src/cmd/create.rs @@ -3,12 +3,13 @@ use std::path::Path; use crate::db; use crate::editor; +use crate::model::{Effort, Priority}; use crate::ops; pub struct Opts<'a> { pub title: Option<&'a str>, - pub priority: db::Priority, - pub effort: db::Effort, + pub priority: Priority, + pub effort: Effort, pub task_type: &'a str, pub desc: Option<&'a str>, pub parent: Option<&'a str>, diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index e71c538687d5b34a650611bc832f6b56a2213c54..f144c4918e0cd749d88c3914435171685205ffc3 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -11,6 +11,7 @@ use anyhow::{anyhow, Result}; use serde::Serialize; use crate::db; +use crate::model::{now_utc, Status, Task, TaskId}; /// Categories of integrity issues that doctor can detect. #[derive(Debug, Clone, Serialize)] @@ -58,8 +59,8 @@ struct Report { /// Repair action to apply when `--fix` is requested. enum Repair { - ClearParent(db::TaskId), - RemoveBlocker(db::TaskId, db::TaskId), + ClearParent(TaskId), + RemoveBlocker(TaskId, TaskId), } pub fn run(root: &Path, fix: bool, json: bool) -> Result<()> { @@ -70,7 +71,7 @@ pub fn run(root: &Path, fix: bool, json: bool) -> Result<()> { let all_ids: HashSet = tasks.iter().map(|t| t.id.as_str().to_string()).collect(); let open_ids: HashSet = tasks .iter() - .filter(|t| t.status != db::Status::Closed && t.deleted_at.is_none()) + .filter(|t| t.status != Status::Closed && t.deleted_at.is_none()) .map(|t| t.id.as_str().to_string()) .collect(); @@ -85,7 +86,7 @@ pub fn run(root: &Path, fix: bool, json: bool) -> Result<()> { if fix && !repairs.is_empty() { store.apply_and_persist(|doc| { let tasks_map = doc.get_map("tasks"); - let ts = db::now_utc(); + let ts = now_utc(); for repair in &repairs { match repair { @@ -130,12 +131,12 @@ pub fn run(root: &Path, fix: bool, json: bool) -> Result<()> { /// /// Skips tombstoned tasks — their stale references are not actionable. fn check_dangling_parents( - tasks: &[db::Task], + tasks: &[Task], all_ids: &HashSet, findings: &mut Vec, repairs: &mut Vec, ) { - let task_map: std::collections::HashMap<&str, &db::Task> = + let task_map: std::collections::HashMap<&str, &Task> = tasks.iter().map(|t| (t.id.as_str(), t)).collect(); for task in tasks { @@ -154,7 +155,7 @@ fn check_dangling_parents( task: task.id.to_string(), detail: format!( "parent references missing task {}", - db::TaskId::display_id(parent.as_str()), + TaskId::display_id(parent.as_str()), ), active: true, fixed: false, @@ -171,7 +172,7 @@ fn check_dangling_parents( task: task.id.to_string(), detail: format!( "parent references tombstoned task {}", - db::TaskId::display_id(parent.as_str()), + TaskId::display_id(parent.as_str()), ), active: true, fixed: false, @@ -186,7 +187,7 @@ fn check_dangling_parents( /// /// Skips tombstoned tasks — their stale references are not actionable. fn check_dangling_blockers( - tasks: &[db::Task], + tasks: &[Task], all_ids: &HashSet, findings: &mut Vec, repairs: &mut Vec, @@ -203,7 +204,7 @@ fn check_dangling_blockers( task: task.id.to_string(), detail: format!( "blocker references missing task {}", - db::TaskId::display_id(blocker.as_str()), + TaskId::display_id(blocker.as_str()), ), active: true, fixed: false, @@ -225,7 +226,7 @@ fn check_dangling_blockers( /// are "inert" — `partition_blockers` already resolves them at runtime. /// Only active cycles are repaired by `--fix`. fn check_blocker_cycles( - tasks: &[db::Task], + tasks: &[Task], all_ids: &HashSet, open_ids: &HashSet, findings: &mut Vec, @@ -267,12 +268,12 @@ fn check_blocker_cycles( .all(|id| open_ids.contains(id)); // Build a human-readable cycle string using short IDs. - let display: Vec = cycle.iter().map(|id| db::TaskId::display_id(id)).collect(); + let display: Vec = cycle.iter().map(|id| TaskId::display_id(id)).collect(); let cycle_str = display.join(" → "); findings.push(Finding { kind: FindingKind::BlockerCycle, - task: db::TaskId::display_id(&task_id), + task: TaskId::display_id(&task_id), detail: cycle_str, active, fixed: false, @@ -280,8 +281,8 @@ fn check_blocker_cycles( if active { repairs.push(Repair::RemoveBlocker( - db::TaskId::parse(&task_id)?, - db::TaskId::parse(&blocker_id)?, + TaskId::parse(&task_id)?, + TaskId::parse(&blocker_id)?, )); } @@ -301,7 +302,7 @@ fn check_blocker_cycles( /// current path, we have a cycle. Repairs clear the parent field on the /// task with the lexicographically lowest ULID in the cycle. fn check_parent_cycles( - tasks: &[db::Task], + tasks: &[Task], all_ids: &HashSet, findings: &mut Vec, repairs: &mut Vec, @@ -338,8 +339,7 @@ fn check_parent_cycles( let mut cycle: Vec = path[pos..].to_vec(); cycle.push(node); // close the loop - let display: Vec = - cycle.iter().map(|id| db::TaskId::display_id(id)).collect(); + let display: Vec = cycle.iter().map(|id| TaskId::display_id(id)).collect(); let cycle_str = display.join(" → "); // The task with the lowest ULID gets its parent cleared. @@ -351,12 +351,12 @@ fn check_parent_cycles( findings.push(Finding { kind: FindingKind::ParentCycle, - task: db::TaskId::display_id(&lowest), + task: TaskId::display_id(&lowest), detail: cycle_str, active: true, fixed: false, }); - repairs.push(Repair::ClearParent(db::TaskId::parse(&lowest)?)); + repairs.push(Repair::ClearParent(TaskId::parse(&lowest)?)); break; } if globally_visited.contains(&node) { diff --git a/src/cmd/done.rs b/src/cmd/done.rs index a10dbeb96e5d0c84c8b90e8ffeb3e6e98cfb5dc6..1fcf77b1d1d18123037e7b39975286cb7647d279 100644 --- a/src/cmd/done.rs +++ b/src/cmd/done.rs @@ -2,6 +2,7 @@ use anyhow::Result; use std::path::Path; use crate::db; +use crate::model::Status; use crate::ops; pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> { @@ -23,7 +24,7 @@ pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> { } else { let c = crate::color::stdout_theme(); for id in &closed { - println!("{}closed{} {id}", c.status(db::Status::Closed), c.reset); + println!("{}closed{} {id}", c.status(Status::Closed), c.reset); } } diff --git a/src/cmd/import.rs b/src/cmd/import.rs index c9aaaa54c21c0db90b66bfa7b77dd59d2a79a44e..d837f9bff721b7a0b1b107b9380d660b7764241a 100644 --- a/src/cmd/import.rs +++ b/src/cmd/import.rs @@ -4,6 +4,7 @@ use std::io::BufRead; use std::path::Path; use crate::db; +use crate::model::{Effort, Priority, Status, TaskId}; #[derive(Deserialize)] struct ImportTask { @@ -68,10 +69,10 @@ pub fn run(root: &Path, file: &str) -> Result<()> { continue; } let t: ImportTask = serde_json::from_str(&line)?; - let id = db::TaskId::parse(&t.id)?; - db::parse_priority(&t.priority)?; - db::parse_status(&t.status)?; - db::parse_effort(&t.effort)?; + let id = TaskId::parse(&t.id)?; + Priority::parse(&t.priority)?; + Status::parse(&t.status)?; + Effort::parse(&t.effort)?; store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); @@ -99,12 +100,12 @@ pub fn run(root: &Path, file: &str) -> Result<()> { let blockers = db::get_or_create_child_map(&task, "blockers")?; for blk in &t.blockers { let parsed = - db::TaskId::parse(blk).map_err(|_| anyhow!("invalid blocker id '{blk}'"))?; + TaskId::parse(blk).map_err(|_| anyhow!("invalid blocker id '{blk}'"))?; blockers.insert(parsed.as_str(), true)?; } let logs = db::get_or_create_child_map(&task, "logs")?; for entry in &t.logs { - let log_id = db::TaskId::parse(&entry.id) + let log_id = TaskId::parse(&entry.id) .map_err(|_| anyhow!("invalid log id '{}'", entry.id))?; let record = logs.get_or_create_container(log_id.as_str(), loro::LoroMap::new())?; record.insert("timestamp", entry.timestamp.clone())?; diff --git a/src/cmd/list.rs b/src/cmd/list.rs index f4f906c2ac68e97152f6303e8d87fc4a1d67c3f1..9cb26e0bd99d4cc3560dcc6a33dd453085123df8 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -5,12 +5,13 @@ use std::path::Path; use crate::color::{cell_bold, cell_effort, cell_priority, cell_status, stdout_use_color}; use crate::db; +use crate::model::{Effort, Priority, Status}; pub fn run( root: &Path, status: Option<&str>, - priority: Option, - effort: Option, + priority: Option, + effort: Option, label: Option<&str>, json: bool, ) -> Result<()> { @@ -18,7 +19,7 @@ pub fn run( let mut tasks = store.list_tasks()?; if let Some(s) = status { - let parsed = db::parse_status(s)?; + let parsed = Status::parse(s)?; tasks.retain(|t| t.status == parsed); } if let Some(p) = priority { @@ -52,13 +53,9 @@ pub fn run( for t in &tasks { table.add_row(vec![ cell_bold(&t.id, use_color), - cell_status( - format!("[{}]", db::status_label(t.status)), - t.status, - use_color, - ), - cell_priority(db::priority_label(t.priority), t.priority, use_color), - cell_effort(db::effort_label(t.effort), t.effort, use_color), + cell_status(format!("[{}]", t.status.as_str()), t.status, use_color), + cell_priority(t.priority.as_str(), t.priority, use_color), + cell_effort(t.effort.as_str(), t.effort, use_color), Cell::new(&t.title), ]); } diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 67c8bd9e858c7b026f5d8d9b50d2135aba89a12b..17fbbbd1096911f6790af29f438fd4dfc359e431 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -23,6 +23,7 @@ mod webui; use crate::cli::{Cli, Command}; use crate::db; +use crate::model::{Effort, Priority}; use anyhow::Result; fn require_root() -> Result { @@ -50,8 +51,8 @@ pub fn dispatch(cli: &Cli) -> Result<()> { &root, create::Opts { title: title.as_deref(), - priority: db::parse_priority(priority)?, - effort: db::parse_effort(effort)?, + priority: Priority::parse(priority)?, + effort: Effort::parse(effort)?, task_type, desc: desc.as_deref(), parent: parent.as_deref(), @@ -67,8 +68,8 @@ pub fn dispatch(cli: &Cli) -> Result<()> { label, } => { let root = require_root()?; - let pri = priority.as_deref().map(db::parse_priority).transpose()?; - let eff = effort.as_deref().map(db::parse_effort).transpose()?; + let pri = priority.as_deref().map(Priority::parse).transpose()?; + let eff = effort.as_deref().map(Effort::parse).transpose()?; list::run( &root, status.as_deref(), @@ -95,8 +96,8 @@ pub fn dispatch(cli: &Cli) -> Result<()> { desc, } => { let root = require_root()?; - let pri = priority.as_deref().map(db::parse_priority).transpose()?; - let eff = effort.as_deref().map(db::parse_effort).transpose()?; + let pri = priority.as_deref().map(Priority::parse).transpose()?; + let eff = effort.as_deref().map(Effort::parse).transpose()?; update::run( &root, id, diff --git a/src/cmd/next.rs b/src/cmd/next.rs index ccdc54cc7757dd70c4d7657aa72db2f878306830..19d6d44fa55fc90187e31bec7d194543a47f6e16 100644 --- a/src/cmd/next.rs +++ b/src/cmd/next.rs @@ -6,6 +6,7 @@ use std::path::Path; use crate::color::{cell_bold, stdout_use_color}; use crate::db; +use crate::model::{Status, TaskId}; use crate::score::{self, Mode}; fn parse_mode(s: &str) -> Result { @@ -23,20 +24,20 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool) let open_tasks: Vec = all .iter() - .filter(|t| t.status == db::Status::Open) + .filter(|t| t.status == Status::Open) .map(|t| score::TaskInput { id: t.id.to_string(), title: t.title.clone(), priority_score: t.priority.score(), effort_score: t.effort.score(), - priority_label: db::priority_label(t.priority).to_string(), - effort_label: db::effort_label(t.effort).to_string(), + priority_label: t.priority.as_str().to_string(), + effort_label: t.effort.as_str().to_string(), }) .collect(); let edges: Vec<(String, String)> = all .iter() - .filter(|t| t.status == db::Status::Open) + .filter(|t| t.status == Status::Open) .flat_map(|t| { t.blockers .iter() @@ -47,7 +48,7 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool) let parents_with_open_children: HashSet = all .iter() - .filter(|t| t.status == db::Status::Open) + .filter(|t| t.status == Status::Open) .filter_map(|t| t.parent.as_ref().map(ToString::to_string)) .collect(); @@ -96,7 +97,7 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool) table.set_header(vec!["#", "ID", "SCORE", "TITLE"]); for (i, s) in scored.iter().enumerate() { - let short = db::TaskId::display_id(&s.id); + let short = TaskId::display_id(&s.id); table.add_row(vec![ Cell::new(i + 1), cell_bold(&short, use_color), @@ -113,7 +114,7 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool) }; println!("\nmode: {mode_label}"); for (i, s) in scored.iter().enumerate() { - let short = db::TaskId::display_id(&s.id); + let short = TaskId::display_id(&s.id); let formula = match mode { Mode::Impact => format!( "({:.2} + 1.00) × {:.2} / {:.2}^0.25 = {:.2}", diff --git a/src/cmd/ready.rs b/src/cmd/ready.rs index aa7fe791e4147936e378966b029b244a43ca9f32..9130f23a24761cd170de5edbab78b60de27909b3 100644 --- a/src/cmd/ready.rs +++ b/src/cmd/ready.rs @@ -5,13 +5,14 @@ use std::path::Path; use crate::color::{cell_bold, cell_effort, cell_priority, stdout_use_color}; use crate::db; +use crate::model::Status; pub fn run(root: &Path, json: bool) -> Result<()> { let store = db::open(root)?; let mut tasks = Vec::new(); for task in store.list_tasks()? { - if task.status != db::Status::Open { + if task.status != Status::Open { continue; } let blockers = db::partition_blockers(&store, &task.blockers)?; @@ -32,8 +33,8 @@ pub fn run(root: &Path, json: bool) -> Result<()> { for t in &tasks { table.add_row(vec![ cell_bold(&t.id, use_color), - cell_priority(db::priority_label(t.priority), t.priority, use_color), - cell_effort(db::effort_label(t.effort), t.effort, use_color), + cell_priority(t.priority.as_str(), t.priority, use_color), + cell_effort(t.effort.as_str(), t.effort, use_color), Cell::new(&t.title), ]); } diff --git a/src/cmd/reopen.rs b/src/cmd/reopen.rs index ddd68145a19d1e99eb67f1cf2cd15613f23ae661..93639f25186bdab4626b47849f887c3f53c60e7b 100644 --- a/src/cmd/reopen.rs +++ b/src/cmd/reopen.rs @@ -2,6 +2,7 @@ use anyhow::Result; use std::path::Path; use crate::db; +use crate::model::Status; use crate::ops; pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> { @@ -23,7 +24,7 @@ pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> { } else { let c = crate::color::stdout_theme(); for id in &reopened { - println!("{}reopened{} {id}", c.status(db::Status::Open), c.reset); + println!("{}reopened{} {id}", c.status(Status::Open), c.reset); } } diff --git a/src/cmd/rm.rs b/src/cmd/rm.rs index 42f14ba33c604e9e84268567fc7f76da2c696af1..39d806bb29296e5019ef15fd884c10de3dd456fb 100644 --- a/src/cmd/rm.rs +++ b/src/cmd/rm.rs @@ -3,6 +3,7 @@ use serde::Serialize; use std::path::Path; use crate::db; +use crate::model::TaskId; use crate::ops; #[derive(Serialize)] @@ -15,7 +16,7 @@ struct RmResult { pub fn run(root: &Path, ids: &[String], recursive: bool, force: bool, json: bool) -> Result<()> { let store = db::open(root)?; - let resolved: Vec = ids + let resolved: Vec = ids .iter() .map(|raw| db::resolve_task_id(&store, raw, false)) .collect::>()?; diff --git a/src/cmd/search.rs b/src/cmd/search.rs index 65bd220e8820e0f9bed0016a3f6e468e5fdd9664..2acd50b44f6beaf3fe93f6dbca995abb8767fd44 100644 --- a/src/cmd/search.rs +++ b/src/cmd/search.rs @@ -5,12 +5,13 @@ use std::path::Path; use crate::color::{cell_bold, stdout_use_color}; use crate::db; +use crate::model::Task; pub fn run(root: &Path, query: &str, json: bool) -> Result<()> { let store = db::open(root)?; let q = query.to_lowercase(); - let tasks: Vec = store + let tasks: Vec = store .list_tasks()? .into_iter() .filter(|t| { diff --git a/src/cmd/show.rs b/src/cmd/show.rs index bf1e652a7e41faf1261588d37c9e5c77ad85f57d..325f3eaeef303288fd0420c10d6e15142877edbe 100644 --- a/src/cmd/show.rs +++ b/src/cmd/show.rs @@ -23,7 +23,7 @@ pub fn run(root: &Path, id: &str, json: bool) -> Result<()> { task.title, c.reset, c.status(task.status), - db::status_label(task.status), + task.status.as_str(), c.reset ); @@ -40,10 +40,10 @@ pub fn run(root: &Path, id: &str, json: bool) -> Result<()> { c.reset, task.task_type, c.priority(task.priority), - db::priority_label(task.priority), + task.priority.as_str(), c.reset, c.effort(task.effort), - db::effort_label(task.effort), + task.effort.as_str(), c.reset, ); diff --git a/src/cmd/stats.rs b/src/cmd/stats.rs index 6cd912fccb9838d76595a9059e15676782186384..ac1be1fed94b975f4e7e1d78bb27fd8fb07f4c82 100644 --- a/src/cmd/stats.rs +++ b/src/cmd/stats.rs @@ -2,24 +2,19 @@ use anyhow::Result; use std::path::Path; use crate::db; +use crate::model::Status; pub fn run(root: &Path) -> Result<()> { let store = db::open(root)?; let tasks = store.list_tasks()?; let total = tasks.len(); - let open = tasks - .iter() - .filter(|t| t.status == db::Status::Open) - .count(); + let open = tasks.iter().filter(|t| t.status == Status::Open).count(); let in_progress = tasks .iter() - .filter(|t| t.status == db::Status::InProgress) - .count(); - let closed = tasks - .iter() - .filter(|t| t.status == db::Status::Closed) + .filter(|t| t.status == Status::InProgress) .count(); + let closed = tasks.iter().filter(|t| t.status == Status::Closed).count(); println!( "{}", diff --git a/src/cmd/update.rs b/src/cmd/update.rs index e2a83db135a58b0a9e8df691812c0b2abb6987de..5a6eaac8dff0a98c18d2dd132c8173f22b2ffe75 100644 --- a/src/cmd/update.rs +++ b/src/cmd/update.rs @@ -3,12 +3,13 @@ use std::path::Path; use crate::db; use crate::editor; +use crate::model::{Effort, Priority, Status}; use crate::ops; pub struct Opts<'a> { pub status: Option<&'a str>, - pub priority: Option, - pub effort: Option, + pub priority: Option, + pub effort: Option, pub title: Option<&'a str>, pub desc: Option<&'a str>, pub json: bool, @@ -18,7 +19,7 @@ pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> { let store = db::open(root)?; let task_id = db::resolve_task_id(&store, id, false)?; - let parsed_status = opts.status.map(db::parse_status).transpose()?; + let parsed_status = opts.status.map(Status::parse).transpose()?; // If no fields were supplied, open the editor so the user can revise the // task's title and description interactively. diff --git a/src/cmd/webui/handlers.rs b/src/cmd/webui/handlers.rs index 2f743e767b3609d6df819889d7f5cc5d6997d460..2bc135f2cbf6330585813da5fb0d051c20d0fc7f 100644 --- a/src/cmd/webui/handlers.rs +++ b/src/cmd/webui/handlers.rs @@ -4,7 +4,8 @@ use anyhow::Result; use axum::extract::{Path as AxumPath, Query, State}; use axum::response::Response; -use crate::db::{self, Store, TaskId}; +use crate::db::{self, Store}; +use crate::model::{Effort, Priority, Status, Task, TaskId}; use crate::score; use super::helpers::{ @@ -29,18 +30,12 @@ pub(super) async fn index_handler(State(state): State) -> Response { match Store::open(&root, name) { Ok(store) => { let tasks = store.list_tasks()?; - let open = tasks - .iter() - .filter(|t| t.status == db::Status::Open) - .count(); + let open = tasks.iter().filter(|t| t.status == Status::Open).count(); let in_progress = tasks .iter() - .filter(|t| t.status == db::Status::InProgress) - .count(); - let closed = tasks - .iter() - .filter(|t| t.status == db::Status::Closed) + .filter(|t| t.status == Status::InProgress) .count(); + let closed = tasks.iter().filter(|t| t.status == Status::Closed).count(); cards.push(ProjectCard::Ok { name: name.clone(), open, @@ -101,18 +96,12 @@ pub(super) async fn project_handler( let tasks = store.list_tasks()?; // Stats from the full unfiltered set. - let stats_open = tasks - .iter() - .filter(|t| t.status == db::Status::Open) - .count(); + let stats_open = tasks.iter().filter(|t| t.status == Status::Open).count(); let stats_in_progress = tasks .iter() - .filter(|t| t.status == db::Status::InProgress) - .count(); - let stats_closed = tasks - .iter() - .filter(|t| t.status == db::Status::Closed) + .filter(|t| t.status == Status::InProgress) .count(); + let stats_closed = tasks.iter().filter(|t| t.status == Status::Closed).count(); // Collect distinct labels for the filter dropdown. let mut label_set: HashSet = HashSet::new(); @@ -127,20 +116,20 @@ pub(super) async fn project_handler( // Next-up scoring (top 5 open tasks). let open_tasks: Vec = tasks .iter() - .filter(|t| t.status == db::Status::Open) + .filter(|t| t.status == Status::Open) .map(|t| score::TaskInput { id: t.id.as_str().to_string(), title: t.title.clone(), priority_score: t.priority.score(), effort_score: t.effort.score(), - priority_label: db::priority_label(t.priority).to_string(), - effort_label: db::effort_label(t.effort).to_string(), + priority_label: t.priority.as_str().to_string(), + effort_label: t.effort.as_str().to_string(), }) .collect(); let edges: Vec<(String, String)> = tasks .iter() - .filter(|t| t.status == db::Status::Open) + .filter(|t| t.status == Status::Open) .flat_map(|t| { t.blockers .iter() @@ -151,7 +140,7 @@ pub(super) async fn project_handler( let parents_with_open_children: HashSet = tasks .iter() - .filter(|t| t.status == db::Status::Open) + .filter(|t| t.status == Status::Open) .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string())) .collect(); @@ -176,25 +165,25 @@ pub(super) async fn project_handler( .collect(); // Apply filters. - let mut filtered: Vec<&db::Task> = tasks.iter().collect(); + let mut filtered: Vec<&Task> = tasks.iter().collect(); if let Some(ref s) = query.status { if !s.is_empty() { - if let Ok(parsed) = db::parse_status(s) { + if let Ok(parsed) = Status::parse(s) { filtered.retain(|t| t.status == parsed); } } } if let Some(ref p) = query.priority { if !p.is_empty() { - if let Ok(parsed) = db::parse_priority(p) { + if let Ok(parsed) = Priority::parse(p) { filtered.retain(|t| t.priority == parsed); } } } if let Some(ref e) = query.effort { if !e.is_empty() { - if let Ok(parsed) = db::parse_effort(e) { + if let Ok(parsed) = Effort::parse(e) { filtered.retain(|t| t.effort == parsed); } } @@ -237,14 +226,14 @@ pub(super) async fn project_handler( let page_tasks: Vec = filtered[start..end] .iter() .map(|t| { - let status = db::status_label(t.status).to_string(); + let status = t.status.as_str().to_string(); TaskRow { full_id: t.id.as_str().to_string(), short_id: t.id.short(), status_display: friendly_status(&status), status, - priority: db::priority_label(t.priority).to_string(), - effort: db::effort_label(t.effort).to_string(), + priority: t.priority.as_str().to_string(), + effort: t.effort.as_str().to_string(), title: t.title.clone(), created_at_display: friendly_date(&t.created_at), created_at: t.created_at.clone(), @@ -350,14 +339,14 @@ pub(super) async fn task_handler( .iter() .filter(|t| t.parent.as_ref() == Some(&task_id)) .map(|t| { - let status = db::status_label(t.status).to_string(); + let status = t.status.as_str().to_string(); TaskRow { full_id: t.id.as_str().to_string(), short_id: t.id.short(), status_display: friendly_status(&status), status, - priority: db::priority_label(t.priority).to_string(), - effort: db::effort_label(t.effort).to_string(), + priority: t.priority.as_str().to_string(), + effort: t.effort.as_str().to_string(), title: t.title.clone(), created_at_display: friendly_date(&t.created_at), created_at: t.created_at.clone(), @@ -371,9 +360,9 @@ pub(super) async fn task_handler( title: task.title.clone(), description: render_markdown(&task.description), task_type: task.task_type.clone(), - status: db::status_label(task.status).to_string(), - priority: db::priority_label(task.priority).to_string(), - effort: db::effort_label(task.effort).to_string(), + status: task.status.as_str().to_string(), + priority: task.priority.as_str().to_string(), + effort: task.effort.as_str().to_string(), created_at_display: friendly_date(&task.created_at), created_at: task.created_at.clone(), updated_at_display: friendly_date(&task.updated_at), diff --git a/src/cmd/webui/helpers.rs b/src/cmd/webui/helpers.rs index 4ebd7cd140133ec9f1ea9d92ad45190fd2fe2670..6052492f0f45db2040c8fd74928e051c64a323a5 100644 --- a/src/cmd/webui/helpers.rs +++ b/src/cmd/webui/helpers.rs @@ -4,6 +4,7 @@ use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::Json; use crate::db; +use crate::model::{Status, Task}; use super::views::ErrorTemplate; @@ -79,16 +80,16 @@ impl SortOrder { /// Map a `Status` to a numeric value for semantic sorting. /// Lower values sort first in ascending order: open → in_progress → closed. -fn status_sort_key(s: db::Status) -> i32 { +fn status_sort_key(s: Status) -> i32 { match s { - db::Status::Open => 1, - db::Status::InProgress => 2, - db::Status::Closed => 3, + Status::Open => 1, + Status::InProgress => 2, + Status::Closed => 3, } } /// Apply the chosen sort field and direction to a filtered task list. -pub(super) fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) { +pub(super) fn sort_tasks(tasks: &mut [&Task], field: SortField, order: SortOrder) { tasks.sort_by(|a, b| { let cmp = match field { SortField::Id => a.id.as_str().cmp(b.id.as_str()), diff --git a/src/cmd/webui/mutations.rs b/src/cmd/webui/mutations.rs index 0071255025484b100ebb9887580305e51596c2ad..73244186f2b3908dafc14b94c1d76eb53f626f3b 100644 --- a/src/cmd/webui/mutations.rs +++ b/src/cmd/webui/mutations.rs @@ -4,7 +4,8 @@ use axum::http::HeaderMap; use axum::response::Response; use axum::Form; -use crate::db::{self, Store, TaskId}; +use crate::db::{self, Store}; +use crate::model::{Effort, LogEntry, Priority, Status, Task, TaskId}; use crate::ops; use super::helpers::{mutation_error, mutation_response}; @@ -115,7 +116,7 @@ pub(super) async fn create_handler( Form(form): Form, ) -> Response { let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> { + let result = tokio::task::spawn_blocking(move || -> Result<(Task, String)> { let store = Store::open(&root, &name)?; let parent = if form.parent.is_empty() { @@ -138,8 +139,8 @@ pub(super) async fn create_handler( title: form.title, description: form.description, task_type: form.task_type, - priority: db::parse_priority(&form.priority)?, - effort: db::parse_effort(&form.effort)?, + priority: Priority::parse(&form.priority)?, + effort: Effort::parse(&form.effort)?, parent, labels, }, @@ -168,7 +169,7 @@ pub(super) async fn update_handler( Form(form): Form, ) -> Response { let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> { + let result = tokio::task::spawn_blocking(move || -> Result<(Task, String)> { let store = Store::open(&root, &name)?; let task_id = db::resolve_task_id(&store, &id, false)?; @@ -180,19 +181,19 @@ pub(super) async fn update_handler( .status .as_deref() .filter(|s| !s.is_empty()) - .map(db::parse_status) + .map(Status::parse) .transpose()?, priority: form .priority .as_deref() .filter(|s| !s.is_empty()) - .map(db::parse_priority) + .map(Priority::parse) .transpose()?, effort: form .effort .as_deref() .filter(|s| !s.is_empty()) - .map(db::parse_effort) + .map(Effort::parse) .transpose()?, title: form.title.filter(|s| !s.is_empty()), description: form.description, @@ -225,7 +226,7 @@ pub(super) async fn log_handler( Form(form): Form, ) -> Response { let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result<(db::LogEntry, String)> { + let result = tokio::task::spawn_blocking(move || -> Result<(LogEntry, String)> { let store = Store::open(&root, &name)?; let task_id = db::resolve_task_id(&store, &id, false)?; let entry = ops::add_log(&store, &task_id, &form.message)?; diff --git a/src/color.rs b/src/color.rs index 0e30290b404d116831d3792c85530dfecbee0b9a..2c9fb5d0007c11ba0b5f5f51d3c5b9da2485ffc2 100644 --- a/src/color.rs +++ b/src/color.rs @@ -1,7 +1,7 @@ use comfy_table::{Attribute, Cell, Color}; use std::io::IsTerminal; -use crate::db; +use crate::model::{Effort, Priority, Status}; pub struct Theme { pub red: &'static str, @@ -88,68 +88,68 @@ pub fn cell_fg(text: impl ToString, color: Color, use_color: bool) -> Cell { impl Theme { /// ANSI escape for a task status. - pub fn status(&self, s: db::Status) -> &str { + pub fn status(&self, s: Status) -> &str { match s { - db::Status::Open => self.green, - db::Status::InProgress => self.bold_yellow, - db::Status::Closed => "", + Status::Open => self.green, + Status::InProgress => self.bold_yellow, + Status::Closed => "", } } /// ANSI escape for a priority level. - pub fn priority(&self, p: db::Priority) -> &str { + pub fn priority(&self, p: Priority) -> &str { match p { - db::Priority::High => self.bold_red, - db::Priority::Medium => "", - db::Priority::Low => self.cyan, + Priority::High => self.bold_red, + Priority::Medium => "", + Priority::Low => self.cyan, } } /// ANSI escape for an effort level. - pub fn effort(&self, e: db::Effort) -> &str { + pub fn effort(&self, e: Effort) -> &str { match e { - db::Effort::High => self.bold_red, - db::Effort::Medium => "", - db::Effort::Low => self.cyan, + Effort::High => self.bold_red, + Effort::Medium => "", + Effort::Low => self.cyan, } } } /// A table cell styled for a task status. -pub fn cell_status(text: impl ToString, s: db::Status, use_color: bool) -> Cell { +pub fn cell_status(text: impl ToString, s: Status, use_color: bool) -> Cell { let cell = Cell::new(text); if !use_color { return cell; } match s { - db::Status::Open => cell.fg(Color::Green), - db::Status::InProgress => cell.fg(Color::Yellow).add_attribute(Attribute::Bold), - db::Status::Closed => cell, + Status::Open => cell.fg(Color::Green), + Status::InProgress => cell.fg(Color::Yellow).add_attribute(Attribute::Bold), + Status::Closed => cell, } } /// A table cell styled for a priority level. -pub fn cell_priority(text: impl ToString, p: db::Priority, use_color: bool) -> Cell { +pub fn cell_priority(text: impl ToString, p: Priority, use_color: bool) -> Cell { let cell = Cell::new(text); if !use_color { return cell; } match p { - db::Priority::High => cell.fg(Color::Red).add_attribute(Attribute::Bold), - db::Priority::Medium => cell, - db::Priority::Low => cell.fg(Color::Cyan), + Priority::High => cell.fg(Color::Red).add_attribute(Attribute::Bold), + Priority::Medium => cell, + Priority::Low => cell.fg(Color::Cyan), } } /// A table cell styled for an effort level. -pub fn cell_effort(text: impl ToString, e: db::Effort, use_color: bool) -> Cell { +pub fn cell_effort(text: impl ToString, e: Effort, use_color: bool) -> Cell { let cell = Cell::new(text); if !use_color { return cell; } match e { - db::Effort::High => cell.fg(Color::Red).add_attribute(Attribute::Bold), - db::Effort::Medium => cell, - db::Effort::Low => cell.fg(Color::Cyan), + Effort::High => cell.fg(Color::Red).add_attribute(Attribute::Bold), + Effort::Medium => cell, + Effort::Low => cell.fg(Color::Cyan), } } diff --git a/src/db.rs b/src/db.rs index b74dfb9e291da2cc770ae5713beb5b0e80527ae6..4b4bedaa211e6127e9ec6ff75203af021ebaf5de 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,7 +3,6 @@ use loro::{Container, ExportMode, LoroDoc, LoroMap, PeerID, ValueOrContainer}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; -use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -11,6 +10,7 @@ use std::path::{Path, PathBuf}; use fs2::FileExt; use ulid::Ulid; +use crate::model::{now_utc, BlockerPartition, Effort, LogEntry, Priority, Status, Task, TaskId}; pub const PROJECT_ENV: &str = "TD_PROJECT"; pub(crate) const PROJECTS_DIR: &str = "projects"; @@ -20,226 +20,6 @@ const BASE_FILE: &str = "base.loro"; const TMP_SUFFIX: &str = ".tmp"; use crate::migrate; -/// Current UTC time in ISO 8601 format. -pub fn now_utc() -> String { - chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() -} - -/// Lifecycle state for a task. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Status { - Open, - InProgress, - Closed, -} - -impl Status { - fn as_str(self) -> &'static str { - match self { - Status::Open => "open", - Status::InProgress => "in_progress", - Status::Closed => "closed", - } - } - - fn parse(raw: &str) -> Result { - match raw { - "open" => Ok(Self::Open), - "in_progress" => Ok(Self::InProgress), - "closed" => Ok(Self::Closed), - _ => bail!("invalid status '{raw}'"), - } - } -} - -/// Priority for task ordering. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Priority { - High, - Medium, - Low, -} - -impl Priority { - fn as_str(self) -> &'static str { - match self { - Priority::High => "high", - Priority::Medium => "medium", - Priority::Low => "low", - } - } - - fn parse(raw: &str) -> Result { - match raw { - "high" => Ok(Self::High), - "medium" => Ok(Self::Medium), - "low" => Ok(Self::Low), - _ => bail!("invalid priority '{raw}'"), - } - } - - pub fn score(self) -> i32 { - match self { - Priority::High => 1, - Priority::Medium => 2, - Priority::Low => 3, - } - } -} - -/// Estimated effort for a task. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Effort { - Low, - Medium, - High, -} - -impl Effort { - fn as_str(self) -> &'static str { - match self { - Effort::Low => "low", - Effort::Medium => "medium", - Effort::High => "high", - } - } - - fn parse(raw: &str) -> Result { - match raw { - "low" => Ok(Self::Low), - "medium" => Ok(Self::Medium), - "high" => Ok(Self::High), - _ => bail!("invalid effort '{raw}'"), - } - } - - pub fn score(self) -> i32 { - match self { - Effort::Low => 1, - Effort::Medium => 2, - Effort::High => 3, - } - } -} - -/// A stable task identifier backed by a ULID. -/// -/// Serializes as the short display form (`td-XXXXXXX`) for user-facing -/// JSON. Use [`TaskId::as_str`] when the full ULID is needed (e.g. -/// for CRDT keys or export round-tripping). -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct TaskId(String); - -impl Serialize for TaskId { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.short()) - } -} - -impl TaskId { - pub fn new(id: Ulid) -> Self { - Self(id.to_string()) - } - - pub fn parse(raw: &str) -> Result { - let id = Ulid::from_string(raw).with_context(|| format!("invalid task id '{raw}'"))?; - Ok(Self::new(id)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - pub fn short(&self) -> String { - format!("td-{}", &self.0[self.0.len() - 7..]) - } - - /// Return a display-friendly short ID from a raw ULID string. - pub fn display_id(raw: &str) -> String { - let n = raw.len(); - if n > 7 { - format!("td-{}", &raw[n - 7..]) - } else { - format!("td-{raw}") - } - } -} - -impl fmt::Display for TaskId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.short()) - } -} - -/// A task log entry embedded in a task record. -#[derive(Debug, Clone, Serialize)] -pub struct LogEntry { - pub id: TaskId, - pub timestamp: String, - pub message: String, -} - -/// Hydrated task data from the CRDT document. -#[derive(Debug, Clone, Serialize)] -pub struct Task { - pub id: TaskId, - pub title: String, - pub description: String, - #[serde(rename = "type")] - pub task_type: String, - pub priority: Priority, - pub status: Status, - pub effort: Effort, - pub parent: Option, - pub created_at: String, - pub updated_at: String, - pub deleted_at: Option, - pub labels: Vec, - pub blockers: Vec, - pub logs: Vec, -} - -impl Task { - /// Serialize this task with full ULIDs instead of short display IDs. - /// - /// Used by `export` so that `import` can round-trip data losslessly — - /// `import` needs the full ULID to recreate exact CRDT keys. - pub fn to_export_value(&self) -> serde_json::Value { - serde_json::json!({ - "id": self.id.as_str(), - "title": self.title, - "description": self.description, - "type": self.task_type, - "priority": self.priority, - "status": self.status, - "effort": self.effort, - "parent": self.parent.as_ref().map(|p| p.as_str()), - "created_at": self.created_at, - "updated_at": self.updated_at, - "deleted_at": self.deleted_at, - "labels": self.labels, - "blockers": self.blockers.iter().map(|b| b.as_str()).collect::>(), - "logs": self.logs.iter().map(|l| { - serde_json::json!({ - "id": l.id.as_str(), - "timestamp": l.timestamp, - "message": l.message, - }) - }).collect::>(), - }) - } -} - -/// Result type for partitioning blockers by task state. -#[derive(Debug, Default, Clone, Serialize)] -pub struct BlockerPartition { - pub open: Vec, - pub resolved: Vec, -} - #[derive(Debug, Default, Clone, Serialize, Deserialize)] struct BindingsFile { #[serde(default)] @@ -572,35 +352,6 @@ impl Store { } } -/// Generate a new task ULID. -pub fn gen_id() -> TaskId { - TaskId::new(Ulid::new()) -} - -pub fn parse_status(s: &str) -> Result { - Status::parse(s) -} - -pub fn parse_priority(s: &str) -> Result { - Priority::parse(s) -} - -pub fn parse_effort(s: &str) -> Result { - Effort::parse(s) -} - -pub fn status_label(s: Status) -> &'static str { - s.as_str() -} - -pub fn priority_label(p: Priority) -> &'static str { - p.as_str() -} - -pub fn effort_label(e: Effort) -> &'static str { - e.as_str() -} - pub fn data_root() -> Result { let home = std::env::var("HOME").context("HOME is not set")?; Ok(PathBuf::from(home).join(".local").join("share").join("td")) diff --git a/src/lib.rs b/src/lib.rs index 56d0ae47d12414b356dc229cf9eda416cdf0c3b2..283f323e98097cb27163eb2e8d53c7712aec6e71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod color; pub mod db; pub mod editor; pub mod migrate; +pub mod model; pub mod ops; pub mod score; diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000000000000000000000000000000000000..2f889dffaa873741bea7482f1dfaaef98b290a38 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,233 @@ +use anyhow::{bail, Context, Result}; +use serde::Serialize; +use std::fmt; +use ulid::Ulid; + +/// Current UTC time in ISO 8601 format. +pub fn now_utc() -> String { + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() +} + +/// Lifecycle state for a task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Open, + InProgress, + Closed, +} + +impl Status { + pub fn as_str(self) -> &'static str { + match self { + Status::Open => "open", + Status::InProgress => "in_progress", + Status::Closed => "closed", + } + } + + pub fn parse(raw: &str) -> Result { + match raw { + "open" => Ok(Self::Open), + "in_progress" => Ok(Self::InProgress), + "closed" => Ok(Self::Closed), + _ => bail!("invalid status '{raw}'"), + } + } +} + +/// Priority for task ordering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Priority { + High, + Medium, + Low, +} + +impl Priority { + pub fn as_str(self) -> &'static str { + match self { + Priority::High => "high", + Priority::Medium => "medium", + Priority::Low => "low", + } + } + + pub fn parse(raw: &str) -> Result { + match raw { + "high" => Ok(Self::High), + "medium" => Ok(Self::Medium), + "low" => Ok(Self::Low), + _ => bail!("invalid priority '{raw}'"), + } + } + + pub fn score(self) -> i32 { + match self { + Priority::High => 1, + Priority::Medium => 2, + Priority::Low => 3, + } + } +} + +/// Estimated effort for a task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Effort { + Low, + Medium, + High, +} + +impl Effort { + pub fn as_str(self) -> &'static str { + match self { + Effort::Low => "low", + Effort::Medium => "medium", + Effort::High => "high", + } + } + + pub fn parse(raw: &str) -> Result { + match raw { + "low" => Ok(Self::Low), + "medium" => Ok(Self::Medium), + "high" => Ok(Self::High), + _ => bail!("invalid effort '{raw}'"), + } + } + + pub fn score(self) -> i32 { + match self { + Effort::Low => 1, + Effort::Medium => 2, + Effort::High => 3, + } + } +} + +/// A stable task identifier backed by a ULID. +/// +/// Serializes as the short display form (`td-XXXXXXX`) for user-facing +/// JSON. Use [`TaskId::as_str`] when the full ULID is needed (e.g. +/// for CRDT keys or export round-tripping). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct TaskId(String); + +impl Serialize for TaskId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.short()) + } +} + +impl TaskId { + pub fn new(id: Ulid) -> Self { + Self(id.to_string()) + } + + pub fn parse(raw: &str) -> Result { + let id = Ulid::from_string(raw).with_context(|| format!("invalid task id '{raw}'"))?; + Ok(Self::new(id)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn short(&self) -> String { + format!("td-{}", &self.0[self.0.len() - 7..]) + } + + /// Return a display-friendly short ID from a raw ULID string. + pub fn display_id(raw: &str) -> String { + let n = raw.len(); + if n > 7 { + format!("td-{}", &raw[n - 7..]) + } else { + format!("td-{raw}") + } + } +} + +impl fmt::Display for TaskId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.short()) + } +} + +/// A task log entry embedded in a task record. +#[derive(Debug, Clone, Serialize)] +pub struct LogEntry { + pub id: TaskId, + pub timestamp: String, + pub message: String, +} + +/// Hydrated task data from the CRDT document. +#[derive(Debug, Clone, Serialize)] +pub struct Task { + pub id: TaskId, + pub title: String, + pub description: String, + #[serde(rename = "type")] + pub task_type: String, + pub priority: Priority, + pub status: Status, + pub effort: Effort, + pub parent: Option, + pub created_at: String, + pub updated_at: String, + pub deleted_at: Option, + pub labels: Vec, + pub blockers: Vec, + pub logs: Vec, +} + +impl Task { + /// Serialize this task with full ULIDs instead of short display IDs. + /// + /// Used by `export` so that `import` can round-trip data losslessly — + /// `import` needs the full ULID to recreate exact CRDT keys. + pub fn to_export_value(&self) -> serde_json::Value { + serde_json::json!({ + "id": self.id.as_str(), + "title": self.title, + "description": self.description, + "type": self.task_type, + "priority": self.priority, + "status": self.status, + "effort": self.effort, + "parent": self.parent.as_ref().map(|p| p.as_str()), + "created_at": self.created_at, + "updated_at": self.updated_at, + "deleted_at": self.deleted_at, + "labels": self.labels, + "blockers": self.blockers.iter().map(|b| b.as_str()).collect::>(), + "logs": self + .logs + .iter() + .map(|l| { + serde_json::json!({ + "id": l.id.as_str(), + "timestamp": l.timestamp, + "message": l.message, + }) + }) + .collect::>(), + }) + } +} + +/// Result type for partitioning blockers by task state. +#[derive(Debug, Default, Clone, Serialize)] +pub struct BlockerPartition { + pub open: Vec, + pub resolved: Vec, +} + +/// Generate a new task ULID. +pub fn gen_id() -> TaskId { + TaskId::new(Ulid::new()) +} diff --git a/src/ops.rs b/src/ops.rs index 8775185c62eaa0b8870d4852092e33e183876a0a..b59d1d1429641502bf26500627eb421b9228326d 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -10,7 +10,8 @@ use std::path::Path; use anyhow::{anyhow, bail, Result}; use loro::LoroMap; -use crate::db::{self, LogEntry, Store, Task, TaskId}; +use crate::db::{self, Store}; +use crate::model::{gen_id, now_utc, Effort, LogEntry, Priority, Status, Task, TaskId}; /// Create a new project and optionally bind a directory path to it. pub fn init_project(root: &Path, name: &str, bind_path: Option<&Path>) -> Result<()> { @@ -27,16 +28,16 @@ pub struct CreateOpts { pub title: String, pub description: String, pub task_type: String, - pub priority: db::Priority, - pub effort: db::Effort, + pub priority: Priority, + pub effort: Effort, pub parent: Option, pub labels: Vec, } /// Create a task and return the hydrated result. pub fn create_task(store: &Store, opts: CreateOpts) -> Result { - let ts = db::now_utc(); - let id = db::gen_id(); + let ts = now_utc(); + let id = gen_id(); store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); @@ -45,9 +46,9 @@ pub fn create_task(store: &Store, opts: CreateOpts) -> Result { task.insert("title", opts.title.as_str())?; task.insert("description", opts.description.as_str())?; task.insert("type", opts.task_type.as_str())?; - task.insert("priority", db::priority_label(opts.priority))?; - task.insert("status", db::status_label(db::Status::Open))?; - task.insert("effort", db::effort_label(opts.effort))?; + task.insert("priority", opts.priority.as_str())?; + task.insert("status", Status::Open.as_str())?; + task.insert("effort", opts.effort.as_str())?; task.insert( "parent", opts.parent.as_ref().map(|p| p.as_str()).unwrap_or(""), @@ -77,29 +78,29 @@ pub fn create_task(store: &Store, opts: CreateOpts) -> Result { /// Input for updating an existing task. All fields are optional; only /// populated fields are written. pub struct UpdateOpts { - pub status: Option, - pub priority: Option, - pub effort: Option, + pub status: Option, + pub priority: Option, + pub effort: Option, pub title: Option, pub description: Option, } /// Update task fields and return the refreshed task. pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result { - let ts = db::now_utc(); + let ts = now_utc(); store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?; if let Some(s) = opts.status { - task.insert("status", db::status_label(s))?; + task.insert("status", s.as_str())?; } if let Some(p) = opts.priority { - task.insert("priority", db::priority_label(p))?; + task.insert("priority", p.as_str())?; } if let Some(e) = opts.effort { - task.insert("effort", db::effort_label(e))?; + task.insert("effort", e.as_str())?; } if let Some(ref t) = opts.title { task.insert("title", t.as_str())?; @@ -118,11 +119,11 @@ pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result< /// Mark a single task as closed. pub fn mark_done(store: &Store, task_id: &TaskId) -> Result<()> { - let ts = db::now_utc(); + let ts = now_utc(); store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); if let Some(task) = db::get_task_map(&tasks, task_id)? { - task.insert("status", db::status_label(db::Status::Closed))?; + task.insert("status", Status::Closed.as_str())?; task.insert("updated_at", ts.clone())?; } Ok(()) @@ -132,11 +133,11 @@ pub fn mark_done(store: &Store, task_id: &TaskId) -> Result<()> { /// Reopen a single closed task. pub fn reopen_task(store: &Store, task_id: &TaskId) -> Result<()> { - let ts = db::now_utc(); + let ts = now_utc(); store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); if let Some(task) = db::get_task_map(&tasks, task_id)? { - task.insert("status", db::status_label(db::Status::Open))?; + task.insert("status", Status::Open.as_str())?; task.insert("updated_at", ts.clone())?; } Ok(()) @@ -146,8 +147,8 @@ pub fn reopen_task(store: &Store, task_id: &TaskId) -> Result<()> { /// Append a log entry to a task and return the entry. pub fn add_log(store: &Store, task_id: &TaskId, message: &str) -> Result { - let log_id = db::gen_id(); - let ts = db::now_utc(); + let log_id = gen_id(); + let ts = now_utc(); store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); @@ -169,7 +170,7 @@ pub fn add_log(store: &Store, task_id: &TaskId, message: &str) -> Result Result<()> { - let ts = db::now_utc(); + let ts = now_utc(); store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?; @@ -183,7 +184,7 @@ pub fn add_label(store: &Store, task_id: &TaskId, label: &str) -> Result<()> { /// Remove a label from a task. pub fn remove_label(store: &Store, task_id: &TaskId, label: &str) -> Result<()> { - let ts = db::now_utc(); + let ts = now_utc(); store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?; @@ -206,7 +207,7 @@ pub fn add_dep(store: &Store, child_id: &TaskId, blocker_id: &TaskId) -> Result< bail!("adding dependency would create a cycle"); } - let ts = db::now_utc(); + let ts = now_utc(); store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); let child_task = @@ -221,7 +222,7 @@ pub fn add_dep(store: &Store, child_id: &TaskId, blocker_id: &TaskId) -> Result< /// Remove a blocker dependency. pub fn remove_dep(store: &Store, child_id: &TaskId, blocker_id: &TaskId) -> Result<()> { - let ts = db::now_utc(); + let ts = now_utc(); store.apply_and_persist(|doc| { let tasks = doc.get_map("tasks"); let child_task = @@ -279,7 +280,7 @@ pub fn soft_delete(store: &Store, ids: &[TaskId], recursive: bool) -> Result Result