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