move task model types from db.rs into model.rs

Amolith created

Change summary

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(-)

Detailed changes

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>,

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<String> = tasks.iter().map(|t| t.id.as_str().to_string()).collect();
     let open_ids: HashSet<String> = 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<String>,
     findings: &mut Vec<Finding>,
     repairs: &mut Vec<Repair>,
 ) {
-    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<String>,
     findings: &mut Vec<Finding>,
     repairs: &mut Vec<Repair>,
@@ -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<String>,
     open_ids: &HashSet<String>,
     findings: &mut Vec<Finding>,
@@ -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<String> = cycle.iter().map(|id| db::TaskId::display_id(id)).collect();
+        let display: Vec<String> = 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<String>,
     findings: &mut Vec<Finding>,
     repairs: &mut Vec<Repair>,
@@ -338,8 +339,7 @@ fn check_parent_cycles(
                 let mut cycle: Vec<String> = path[pos..].to_vec();
                 cycle.push(node); // close the loop
 
-                let display: Vec<String> =
-                    cycle.iter().map(|id| db::TaskId::display_id(id)).collect();
+                let display: Vec<String> = 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) {

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);
         }
     }
 

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())?;

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<db::Priority>,
-    effort: Option<db::Effort>,
+    priority: Option<Priority>,
+    effort: Option<Effort>,
     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),
             ]);
         }

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<std::path::PathBuf> {
@@ -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,

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<Mode> {
@@ -23,20 +24,20 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool)
 
     let open_tasks: Vec<score::TaskInput> = 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<String> = 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}",

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),
             ]);
         }

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);
         }
     }
 

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<db::TaskId> = ids
+    let resolved: Vec<TaskId> = ids
         .iter()
         .map(|raw| db::resolve_task_id(&store, raw, false))
         .collect::<Result<_>>()?;

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<db::Task> = store
+    let tasks: Vec<Task> = store
         .list_tasks()?
         .into_iter()
         .filter(|t| {

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,
     );
 

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!(
         "{}",

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<db::Priority>,
-    pub effort: Option<db::Effort>,
+    pub priority: Option<Priority>,
+    pub effort: Option<Effort>,
     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.

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<AppState>) -> 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<String> = HashSet::new();
@@ -127,20 +116,20 @@ pub(super) async fn project_handler(
         // Next-up scoring (top 5 open tasks).
         let open_tasks: Vec<score::TaskInput> = 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<String> = 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<TaskRow> = 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),

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()),

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<CreateForm>,
 ) -> 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<UpdateForm>,
 ) -> 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<LogForm>,
 ) -> 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)?;

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),
     }
 }

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<Self> {
-        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<Self> {
-        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<Self> {
-        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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
-        serializer.serialize_str(&self.short())
-    }
-}
-
-impl TaskId {
-    pub fn new(id: Ulid) -> Self {
-        Self(id.to_string())
-    }
-
-    pub fn parse(raw: &str) -> Result<Self> {
-        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<TaskId>,
-    pub created_at: String,
-    pub updated_at: String,
-    pub deleted_at: Option<String>,
-    pub labels: Vec<String>,
-    pub blockers: Vec<TaskId>,
-    pub logs: Vec<LogEntry>,
-}
-
-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::<Vec<_>>(),
-            "logs": self.logs.iter().map(|l| {
-                serde_json::json!({
-                    "id": l.id.as_str(),
-                    "timestamp": l.timestamp,
-                    "message": l.message,
-                })
-            }).collect::<Vec<_>>(),
-        })
-    }
-}
-
-/// Result type for partitioning blockers by task state.
-#[derive(Debug, Default, Clone, Serialize)]
-pub struct BlockerPartition {
-    pub open: Vec<TaskId>,
-    pub resolved: Vec<TaskId>,
-}
-
 #[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> {
-    Status::parse(s)
-}
-
-pub fn parse_priority(s: &str) -> Result<Priority> {
-    Priority::parse(s)
-}
-
-pub fn parse_effort(s: &str) -> Result<Effort> {
-    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<PathBuf> {
     let home = std::env::var("HOME").context("HOME is not set")?;
     Ok(PathBuf::from(home).join(".local").join("share").join("td"))

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;
 

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<Self> {
+        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<Self> {
+        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<Self> {
+        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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+        serializer.serialize_str(&self.short())
+    }
+}
+
+impl TaskId {
+    pub fn new(id: Ulid) -> Self {
+        Self(id.to_string())
+    }
+
+    pub fn parse(raw: &str) -> Result<Self> {
+        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<TaskId>,
+    pub created_at: String,
+    pub updated_at: String,
+    pub deleted_at: Option<String>,
+    pub labels: Vec<String>,
+    pub blockers: Vec<TaskId>,
+    pub logs: Vec<LogEntry>,
+}
+
+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::<Vec<_>>(),
+            "logs": self
+                .logs
+                .iter()
+                .map(|l| {
+                    serde_json::json!({
+                        "id": l.id.as_str(),
+                        "timestamp": l.timestamp,
+                        "message": l.message,
+                    })
+                })
+                .collect::<Vec<_>>(),
+        })
+    }
+}
+
+/// Result type for partitioning blockers by task state.
+#[derive(Debug, Default, Clone, Serialize)]
+pub struct BlockerPartition {
+    pub open: Vec<TaskId>,
+    pub resolved: Vec<TaskId>,
+}
+
+/// Generate a new task ULID.
+pub fn gen_id() -> TaskId {
+    TaskId::new(Ulid::new())
+}

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<TaskId>,
     pub labels: Vec<String>,
 }
 
 /// Create a task and return the hydrated result.
 pub fn create_task(store: &Store, opts: CreateOpts) -> Result<Task> {
-    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> {
         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<Task> {
 /// Input for updating an existing task.  All fields are optional; only
 /// populated fields are written.
 pub struct UpdateOpts {
-    pub status: Option<db::Status>,
-    pub priority: Option<db::Priority>,
-    pub effort: Option<db::Effort>,
+    pub status: Option<Status>,
+    pub priority: Option<Priority>,
+    pub effort: Option<Effort>,
     pub title: Option<String>,
     pub description: Option<String>,
 }
 
 /// Update task fields and return the refreshed task.
 pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result<Task> {
-    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<LogEntry> {
-    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<LogEntr
 
 /// Add a label to a task.
 pub fn add_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"))?;
@@ -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<Del
         .map(|t| t.id.clone())
         .collect();
 
-    let ts = db::now_utc();
+    let ts = now_utc();
     store.apply_and_persist(|doc| {
         let tasks = doc.get_map("tasks");
 
@@ -288,7 +289,7 @@ pub fn soft_delete(store: &Store, ids: &[TaskId], recursive: bool) -> Result<Del
                 db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
             task.insert("deleted_at", ts.clone())?;
             task.insert("updated_at", ts.clone())?;
-            task.insert("status", db::status_label(db::Status::Closed))?;
+            task.insert("status", Status::Closed.as_str())?;
         }
 
         for task in store.list_tasks_unfiltered()? {

tests/cli_sync.rs 🔗

@@ -39,6 +39,7 @@ fn sync_invalid_code_format_fails() {
 fn sync_exchanges_tasks_between_peers() {
     use std::fs;
     use yatd::db;
+    use yatd::model;
 
     let home_a = tempfile::tempdir().unwrap();
     let cwd_a = tempfile::tempdir().unwrap();
@@ -48,7 +49,7 @@ fn sync_exchanges_tasks_between_peers() {
     // --- Set up peer A: init a project and create a task ---
     std::env::set_var("HOME", home_a.path());
     let store_a = db::init(cwd_a.path(), "shared").unwrap();
-    let id_a = db::gen_id();
+    let id_a = model::gen_id();
     store_a
         .apply_and_persist(|doc| {
             let tasks = doc.get_map("tasks");
@@ -60,8 +61,8 @@ fn sync_exchanges_tasks_between_peers() {
             task.insert("status", "open")?;
             task.insert("effort", "medium")?;
             task.insert("parent", "")?;
-            task.insert("created_at", db::now_utc())?;
-            task.insert("updated_at", db::now_utc())?;
+            task.insert("created_at", model::now_utc())?;
+            task.insert("updated_at", model::now_utc())?;
             task.insert("deleted_at", "")?;
             task.insert_container("labels", loro::LoroMap::new())?;
             task.insert_container("blockers", loro::LoroMap::new())?;
@@ -97,7 +98,7 @@ fn sync_exchanges_tasks_between_peers() {
 
     std::env::set_var("HOME", home_b.path());
     let store_b = db::open(cwd_b.path()).unwrap();
-    let id_b = db::gen_id();
+    let id_b = model::gen_id();
     store_b
         .apply_and_persist(|doc| {
             let tasks = doc.get_map("tasks");
@@ -109,8 +110,8 @@ fn sync_exchanges_tasks_between_peers() {
             task.insert("status", "open")?;
             task.insert("effort", "low")?;
             task.insert("parent", "")?;
-            task.insert("created_at", db::now_utc())?;
-            task.insert("updated_at", db::now_utc())?;
+            task.insert("created_at", model::now_utc())?;
+            task.insert("updated_at", model::now_utc())?;
             task.insert("deleted_at", "")?;
             task.insert_container("labels", loro::LoroMap::new())?;
             task.insert_container("blockers", loro::LoroMap::new())?;
@@ -194,13 +195,14 @@ fn try_open_returns_none_without_binding() {
 #[test]
 fn bootstrap_from_peer_creates_openable_store() {
     use yatd::db;
+    use yatd::model;
 
     let home_a = tempfile::tempdir().unwrap();
     let cwd_a = tempfile::tempdir().unwrap();
     std::env::set_var("HOME", home_a.path());
     let source = db::init(cwd_a.path(), "shared").unwrap();
 
-    let id = db::gen_id();
+    let id = model::gen_id();
     source
         .apply_and_persist(|doc| {
             let tasks = doc.get_map("tasks");
@@ -212,8 +214,8 @@ fn bootstrap_from_peer_creates_openable_store() {
             task.insert("status", "open")?;
             task.insert("effort", "medium")?;
             task.insert("parent", "")?;
-            task.insert("created_at", db::now_utc())?;
-            task.insert("updated_at", db::now_utc())?;
+            task.insert("created_at", model::now_utc())?;
+            task.insert("updated_at", model::now_utc())?;
             task.insert("deleted_at", "")?;
             task.insert_container("labels", loro::LoroMap::new())?;
             task.insert_container("blockers", loro::LoroMap::new())?;
@@ -274,7 +276,7 @@ fn bootstrap_from_peer_rejects_missing_project_id() {
 
 /// Helper: insert a minimal valid task into a doc via apply_and_persist.
 fn insert_task(store: &yatd::db::Store, title: &str) {
-    let id = yatd::db::gen_id();
+    let id = yatd::model::gen_id();
     store
         .apply_and_persist(|doc| {
             let tasks = doc.get_map("tasks");
@@ -286,8 +288,8 @@ fn insert_task(store: &yatd::db::Store, title: &str) {
             task.insert("status", "open")?;
             task.insert("effort", "medium")?;
             task.insert("parent", "")?;
-            task.insert("created_at", yatd::db::now_utc())?;
-            task.insert("updated_at", yatd::db::now_utc())?;
+            task.insert("created_at", yatd::model::now_utc())?;
+            task.insert("updated_at", yatd::model::now_utc())?;
             task.insert("deleted_at", "")?;
             task.insert_container("labels", loro::LoroMap::new())?;
             task.insert_container("blockers", loro::LoroMap::new())?;