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}