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}