ops.rs

  1//! Shared mutation helpers for task operations.
  2//!
  3//! These functions encapsulate the Loro document mutations so that both
  4//! CLI commands and web handlers can perform the same operations without
  5//! duplicating store logic.
  6
  7use std::collections::{HashMap, HashSet, VecDeque};
  8use std::path::Path;
  9
 10use anyhow::{anyhow, bail, Result};
 11use loro::LoroMap;
 12
 13use crate::db::{self, LogEntry, Store, Task, TaskId};
 14
 15/// Create a new project and optionally bind a directory path to it.
 16pub fn init_project(root: &Path, name: &str, bind_path: Option<&Path>) -> Result<()> {
 17    std::fs::create_dir_all(root.join(db::PROJECTS_DIR))?;
 18    Store::init(root, name)?;
 19    if let Some(path) = bind_path {
 20        db::use_project(path, name)?;
 21    }
 22    Ok(())
 23}
 24
 25/// Input for creating a new task.
 26pub struct CreateOpts {
 27    pub title: String,
 28    pub description: String,
 29    pub task_type: String,
 30    pub priority: db::Priority,
 31    pub effort: db::Effort,
 32    pub parent: Option<TaskId>,
 33    pub labels: Vec<String>,
 34}
 35
 36/// Create a task and return the hydrated result.
 37pub fn create_task(store: &Store, opts: CreateOpts) -> Result<Task> {
 38    let ts = db::now_utc();
 39    let id = db::gen_id();
 40
 41    store.apply_and_persist(|doc| {
 42        let tasks = doc.get_map("tasks");
 43        let task = db::insert_task_map(&tasks, &id)?;
 44
 45        task.insert("title", opts.title.as_str())?;
 46        task.insert("description", opts.description.as_str())?;
 47        task.insert("type", opts.task_type.as_str())?;
 48        task.insert("priority", db::priority_label(opts.priority))?;
 49        task.insert("status", db::status_label(db::Status::Open))?;
 50        task.insert("effort", db::effort_label(opts.effort))?;
 51        task.insert(
 52            "parent",
 53            opts.parent.as_ref().map(|p| p.as_str()).unwrap_or(""),
 54        )?;
 55        task.insert("created_at", ts.clone())?;
 56        task.insert("updated_at", ts.clone())?;
 57        task.insert("deleted_at", "")?;
 58        task.insert_container("labels", LoroMap::new())?;
 59        task.insert_container("blockers", LoroMap::new())?;
 60        task.insert_container("logs", LoroMap::new())?;
 61
 62        if !opts.labels.is_empty() {
 63            let labels = db::get_or_create_child_map(&task, "labels")?;
 64            for lbl in &opts.labels {
 65                labels.insert(lbl, true)?;
 66            }
 67        }
 68
 69        Ok(())
 70    })?;
 71
 72    store
 73        .get_task(&id, false)?
 74        .ok_or_else(|| anyhow!("failed to reload created task"))
 75}
 76
 77/// Input for updating an existing task.  All fields are optional; only
 78/// populated fields are written.
 79pub struct UpdateOpts {
 80    pub status: Option<db::Status>,
 81    pub priority: Option<db::Priority>,
 82    pub effort: Option<db::Effort>,
 83    pub title: Option<String>,
 84    pub description: Option<String>,
 85}
 86
 87/// Update task fields and return the refreshed task.
 88pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result<Task> {
 89    let ts = db::now_utc();
 90
 91    store.apply_and_persist(|doc| {
 92        let tasks = doc.get_map("tasks");
 93        let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
 94
 95        if let Some(s) = opts.status {
 96            task.insert("status", db::status_label(s))?;
 97        }
 98        if let Some(p) = opts.priority {
 99            task.insert("priority", db::priority_label(p))?;
100        }
101        if let Some(e) = opts.effort {
102            task.insert("effort", db::effort_label(e))?;
103        }
104        if let Some(ref t) = opts.title {
105            task.insert("title", t.as_str())?;
106        }
107        if let Some(ref d) = opts.description {
108            task.insert("description", d.as_str())?;
109        }
110        task.insert("updated_at", ts.clone())?;
111        Ok(())
112    })?;
113
114    store
115        .get_task(task_id, false)?
116        .ok_or_else(|| anyhow!("task not found"))
117}
118
119/// Mark a single task as closed.
120pub fn mark_done(store: &Store, task_id: &TaskId) -> Result<()> {
121    let ts = db::now_utc();
122    store.apply_and_persist(|doc| {
123        let tasks = doc.get_map("tasks");
124        if let Some(task) = db::get_task_map(&tasks, task_id)? {
125            task.insert("status", db::status_label(db::Status::Closed))?;
126            task.insert("updated_at", ts.clone())?;
127        }
128        Ok(())
129    })?;
130    Ok(())
131}
132
133/// Reopen a single closed task.
134pub fn reopen_task(store: &Store, task_id: &TaskId) -> Result<()> {
135    let ts = db::now_utc();
136    store.apply_and_persist(|doc| {
137        let tasks = doc.get_map("tasks");
138        if let Some(task) = db::get_task_map(&tasks, task_id)? {
139            task.insert("status", db::status_label(db::Status::Open))?;
140            task.insert("updated_at", ts.clone())?;
141        }
142        Ok(())
143    })?;
144    Ok(())
145}
146
147/// Append a log entry to a task and return the entry.
148pub fn add_log(store: &Store, task_id: &TaskId, message: &str) -> Result<LogEntry> {
149    let log_id = db::gen_id();
150    let ts = db::now_utc();
151
152    store.apply_and_persist(|doc| {
153        let tasks = doc.get_map("tasks");
154        let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
155        let logs = db::get_or_create_child_map(&task, "logs")?;
156        let entry = logs.insert_container(log_id.as_str(), LoroMap::new())?;
157        entry.insert("timestamp", ts.clone())?;
158        entry.insert("message", message)?;
159        task.insert("updated_at", ts.clone())?;
160        Ok(())
161    })?;
162
163    Ok(LogEntry {
164        id: log_id,
165        timestamp: ts,
166        message: message.to_string(),
167    })
168}
169
170/// Add a label to a task.
171pub fn add_label(store: &Store, task_id: &TaskId, label: &str) -> Result<()> {
172    let ts = db::now_utc();
173    store.apply_and_persist(|doc| {
174        let tasks = doc.get_map("tasks");
175        let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
176        let labels = db::get_or_create_child_map(&task, "labels")?;
177        labels.insert(label, true)?;
178        task.insert("updated_at", ts.clone())?;
179        Ok(())
180    })?;
181    Ok(())
182}
183
184/// Remove a label from a task.
185pub fn remove_label(store: &Store, task_id: &TaskId, label: &str) -> Result<()> {
186    let ts = db::now_utc();
187    store.apply_and_persist(|doc| {
188        let tasks = doc.get_map("tasks");
189        let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
190        let labels = db::get_or_create_child_map(&task, "labels")?;
191        labels.delete(label)?;
192        task.insert("updated_at", ts.clone())?;
193        Ok(())
194    })?;
195    Ok(())
196}
197
198/// Add a blocker dependency (child is blocked by blocker).
199///
200/// Rejects self-references and cycles.
201pub fn add_dep(store: &Store, child_id: &TaskId, blocker_id: &TaskId) -> Result<()> {
202    if child_id == blocker_id {
203        bail!("adding dependency would create a cycle");
204    }
205    if would_cycle(store, child_id, blocker_id)? {
206        bail!("adding dependency would create a cycle");
207    }
208
209    let ts = db::now_utc();
210    store.apply_and_persist(|doc| {
211        let tasks = doc.get_map("tasks");
212        let child_task =
213            db::get_task_map(&tasks, child_id)?.ok_or_else(|| anyhow!("task not found"))?;
214        let blockers = db::get_or_create_child_map(&child_task, "blockers")?;
215        blockers.insert(blocker_id.as_str(), true)?;
216        child_task.insert("updated_at", ts.clone())?;
217        Ok(())
218    })?;
219    Ok(())
220}
221
222/// Remove a blocker dependency.
223pub fn remove_dep(store: &Store, child_id: &TaskId, blocker_id: &TaskId) -> Result<()> {
224    let ts = db::now_utc();
225    store.apply_and_persist(|doc| {
226        let tasks = doc.get_map("tasks");
227        let child_task =
228            db::get_task_map(&tasks, child_id)?.ok_or_else(|| anyhow!("task not found"))?;
229        let blockers = db::get_or_create_child_map(&child_task, "blockers")?;
230        blockers.delete(blocker_id.as_str())?;
231        child_task.insert("updated_at", ts.clone())?;
232        Ok(())
233    })?;
234    Ok(())
235}
236
237/// Result of a soft-delete operation.
238pub struct DeleteResult {
239    pub deleted_ids: Vec<TaskId>,
240    pub unblocked_ids: Vec<TaskId>,
241}
242
243/// Soft-delete one or more tasks.
244///
245/// When `recursive` is true, subtrees rooted at the given IDs are also
246/// deleted.  Blocker references from surviving tasks are cleaned up
247/// automatically; pass `force` to suppress the warning about unblocked
248/// tasks.
249pub fn soft_delete(store: &Store, ids: &[TaskId], recursive: bool) -> Result<DeleteResult> {
250    use std::collections::BTreeSet;
251
252    let all = store.list_tasks_unfiltered()?;
253
254    let mut to_delete = BTreeSet::new();
255    for id in ids {
256        if recursive {
257            collect_subtree(&all, id, &mut to_delete);
258        } else {
259            if all
260                .iter()
261                .any(|t| t.parent.as_ref() == Some(id) && t.deleted_at.is_none())
262            {
263                bail!("task '{id}' has children; use --recursive to delete subtree");
264            }
265            to_delete.insert(id.clone());
266        }
267    }
268
269    let deleted_ids: Vec<TaskId> = to_delete.into_iter().collect();
270    let deleted_set: HashSet<String> = deleted_ids
271        .iter()
272        .map(|id| id.as_str().to_string())
273        .collect();
274
275    let unblocked_ids: Vec<TaskId> = all
276        .iter()
277        .filter(|t| !deleted_set.contains(t.id.as_str()))
278        .filter(|t| t.blockers.iter().any(|b| deleted_set.contains(b.as_str())))
279        .map(|t| t.id.clone())
280        .collect();
281
282    let ts = db::now_utc();
283    store.apply_and_persist(|doc| {
284        let tasks = doc.get_map("tasks");
285
286        for task_id in &deleted_ids {
287            let task =
288                db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
289            task.insert("deleted_at", ts.clone())?;
290            task.insert("updated_at", ts.clone())?;
291            task.insert("status", db::status_label(db::Status::Closed))?;
292        }
293
294        for task in store.list_tasks_unfiltered()? {
295            if deleted_set.contains(task.id.as_str()) {
296                continue;
297            }
298            if let Some(task_map) = db::get_task_map(&tasks, &task.id)? {
299                let blockers = db::get_or_create_child_map(&task_map, "blockers")?;
300                for deleted in &deleted_ids {
301                    blockers.delete(deleted.as_str())?;
302                }
303            }
304        }
305
306        Ok(())
307    })?;
308
309    Ok(DeleteResult {
310        deleted_ids,
311        unblocked_ids,
312    })
313}
314
315fn would_cycle(store: &Store, child: &TaskId, parent: &TaskId) -> Result<bool> {
316    let tasks = store.list_tasks_unfiltered()?;
317    let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
318    for task in tasks {
319        for blocker in task.blockers {
320            graph
321                .entry(task.id.as_str().to_string())
322                .or_default()
323                .insert(blocker.as_str().to_string());
324        }
325    }
326    graph
327        .entry(child.as_str().to_string())
328        .or_default()
329        .insert(parent.as_str().to_string());
330
331    let mut seen = HashSet::new();
332    let mut queue = VecDeque::from([parent.as_str().to_string()]);
333    while let Some(node) = queue.pop_front() {
334        if node == child.as_str() {
335            return Ok(true);
336        }
337        if !seen.insert(node.clone()) {
338            continue;
339        }
340        if let Some(nexts) = graph.get(&node) {
341            queue.extend(nexts.iter().cloned());
342        }
343    }
344
345    Ok(false)
346}
347
348fn collect_subtree(all: &[Task], root: &TaskId, out: &mut std::collections::BTreeSet<TaskId>) {
349    if !out.insert(root.clone()) {
350        return;
351    }
352    for task in all {
353        if task.parent.as_ref() == Some(root) && task.deleted_at.is_none() {
354            collect_subtree(all, &task.id, out);
355        }
356    }
357}