Skip to main content

todoapp_app/
tasks.rs

1//! Capture, edit, structure, and delegation use cases (FR-1..FR-12).
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use todoapp_core::{
6    Attachment, AttachmentKind, Command, ComponentStore, Date, Due, Duration, Id, IssueRef, Link,
7    LinkKind, Position, Recurrence, Status, Tags, TaskEntityStore, Title,
8};
9
10use crate::service::{Error, Services, TaskSnapshot};
11
12/// Where to drop a task among its new siblings.
13pub enum Anchor {
14    Before(Id),
15    After(Id),
16}
17
18impl<'a, St: ComponentStore + TaskEntityStore> Services<'a, St> {
19    /// FR-1/FR-2: create one task, optionally under `parent` (appended last).
20    pub async fn create(
21        &self,
22        title: impl Into<String>,
23        parent: Option<&Id>,
24        status: Status,
25        tags: impl IntoIterator<Item = String>,
26    ) -> Result<TaskSnapshot, Error> {
27        let id = self.ids.next_id();
28        let now = self.clock.now();
29        self.store.create(&id, now, now).await;
30        self.store.set(&id, Title(title.into())).await;
31        self.store.set(&id, status).await;
32        let tags: BTreeSet<String> = tags.into_iter().collect();
33        if !tags.is_empty() {
34            self.store.set(&id, Tags(tags)).await;
35        }
36        // Top-level tasks attach under the virtual-root sentinel (spec §7), so a
37        // root is just another `child` edge and `roots()` is a plain port query.
38        let root = Id::root();
39        self.attach(&id, parent.unwrap_or(&root), None).await?;
40        self.snapshot(&id).await
41    }
42
43    /// FR-1: batch-create from text, indentation (2 spaces or a tab) = depth
44    /// (spec §13 Q7 default). Returns tasks in document order.
45    pub async fn batch_create(&self, text: &str) -> Result<Vec<TaskSnapshot>, Error> {
46        let mut created = Vec::new();
47        // stack[d] = id of the most recent task at depth d (its children sit at d+1).
48        let mut stack: Vec<Id> = Vec::new();
49        for raw in text.lines() {
50            let title = raw.trim();
51            if title.is_empty() {
52                continue;
53            }
54            let depth = indent_depth(raw);
55            stack.truncate(depth);
56            let parent = stack.last().cloned();
57            let task = self
58                .create(title, parent.as_ref(), Status::Draft, [])
59                .await?;
60            stack.push(task.id.clone());
61            created.push(task);
62        }
63        Ok(created)
64    }
65
66    // ---- task-local edits (thin wrappers over the decider) ----------------
67
68    pub async fn set_title(
69        &self,
70        id: &Id,
71        title: impl Into<String>,
72    ) -> Result<TaskSnapshot, Error> {
73        self.run(id, Command::SetTitle(title.into())).await
74    }
75    pub async fn set_notes(&self, id: &Id, notes: Option<String>) -> Result<TaskSnapshot, Error> {
76        self.run(id, Command::SetNotes(notes)).await
77    }
78    pub async fn set_status(&self, id: &Id, status: Status) -> Result<TaskSnapshot, Error> {
79        self.run(id, Command::SetStatus(status)).await
80    }
81    pub async fn set_due(&self, id: &Id, due: Option<Due>) -> Result<TaskSnapshot, Error> {
82        self.run(id, Command::SetSchedule(due)).await
83    }
84    pub async fn set_estimate(
85        &self,
86        id: &Id,
87        estimate: Option<Duration>,
88    ) -> Result<TaskSnapshot, Error> {
89        self.run(id, Command::SetEstimate(estimate)).await
90    }
91    pub async fn add_time_spent(&self, id: &Id, spent: Duration) -> Result<TaskSnapshot, Error> {
92        self.run(id, Command::AddTimeSpent(spent)).await
93    }
94    pub async fn add_tag(&self, id: &Id, tag: impl Into<String>) -> Result<TaskSnapshot, Error> {
95        self.run(id, Command::AddTag(tag.into())).await
96    }
97    pub async fn remove_tag(&self, id: &Id, tag: impl Into<String>) -> Result<TaskSnapshot, Error> {
98        self.run(id, Command::RemoveTag(tag.into())).await
99    }
100    pub async fn assign(&self, id: &Id, actor: Id) -> Result<TaskSnapshot, Error> {
101        self.run(id, Command::Assign(actor)).await
102    }
103    pub async fn unassign(&self, id: &Id, actor: Id) -> Result<TaskSnapshot, Error> {
104        self.run(id, Command::Unassign(actor)).await
105    }
106    /// FR-11: claim a `todo` task (open if unassigned, else assignee-only).
107    pub async fn claim(&self, id: &Id, actor: Id) -> Result<TaskSnapshot, Error> {
108        self.run(id, Command::Claim(actor)).await
109    }
110    /// A recurring task resets in place on completion instead of staying
111    /// `done` (see `Event::StatusSet(Status::Done)`'s apply arm).
112    pub async fn set_recurrence(
113        &self,
114        id: &Id,
115        recurrence: Option<Recurrence>,
116    ) -> Result<TaskSnapshot, Error> {
117        self.run(id, Command::SetRecurrence(recurrence)).await
118    }
119    pub async fn set_issue_ref(
120        &self,
121        id: &Id,
122        issue_ref: Option<IssueRef>,
123    ) -> Result<TaskSnapshot, Error> {
124        self.run(id, Command::SetIssueRef(issue_ref)).await
125    }
126    pub async fn set_time_log(
127        &self,
128        id: &Id,
129        time_log: BTreeMap<Date, Duration>,
130    ) -> Result<TaskSnapshot, Error> {
131        self.run(id, Command::SetTimeLog(time_log)).await
132    }
133    pub async fn set_archived(&self, id: &Id, archived: bool) -> Result<TaskSnapshot, Error> {
134        self.run(id, Command::SetArchived(archived)).await
135    }
136    /// Attach a file's actual bytes (stored via `BlobStore`) to a task.
137    pub async fn add_attachment_from_bytes(
138        &self,
139        id: &Id,
140        title: impl Into<String>,
141        bytes: Vec<u8>,
142        mime: Option<String>,
143    ) -> Result<TaskSnapshot, Error> {
144        let blob = self.blobs.put(bytes).await;
145        let att = Attachment {
146            id: self.ids.next_id(),
147            kind: AttachmentKind::File,
148            title: title.into(),
149            url: None,
150            blob: Some(blob),
151            mime,
152        };
153        self.run(id, Command::AddAttachment(att)).await
154    }
155    /// Attach a bare link (no stored bytes) to a task.
156    pub async fn add_attachment_link(
157        &self,
158        id: &Id,
159        title: impl Into<String>,
160        url: impl Into<String>,
161    ) -> Result<TaskSnapshot, Error> {
162        let att = Attachment {
163            id: self.ids.next_id(),
164            kind: AttachmentKind::Link,
165            title: title.into(),
166            url: Some(url.into()),
167            blob: None,
168            mime: None,
169        };
170        self.run(id, Command::AddAttachment(att)).await
171    }
172    pub async fn remove_attachment(
173        &self,
174        id: &Id,
175        attachment_id: Id,
176    ) -> Result<TaskSnapshot, Error> {
177        self.run(id, Command::RemoveAttachment(attachment_id)).await
178    }
179
180    // ---- structure (FR-4..FR-8): graph-aware, validated here, not in decide -
181
182    /// Re-point `id`'s single `child` parent to `parent` at `anchor` (FR-8).
183    /// Rejects a move under the task's own subtree (cycle).
184    pub async fn move_task(
185        &self,
186        id: &Id,
187        parent: &Id,
188        anchor: Option<Anchor>,
189    ) -> Result<(), Error> {
190        if parent == id || self.descendants(id).await.contains(parent) {
191            return Err(Error::Cycle(format!("{id} cannot be moved under {parent}")));
192        }
193        if let Some(old) = self.raw_parent_of(id).await {
194            self.links.remove(&old, id, LinkKind::Child).await;
195        }
196        self.attach(id, parent, anchor).await
197    }
198
199    /// Reorder `id` among its existing siblings (FR-7).
200    pub async fn reorder(&self, id: &Id, anchor: Anchor) -> Result<(), Error> {
201        // raw parent so a top-level task reorders among the sentinel's children.
202        let parent = self
203            .raw_parent_of(id)
204            .await
205            .ok_or_else(|| Error::Cycle(format!("{id} has no parent to reorder within")))?;
206        self.attach(id, &parent, Some(anchor)).await
207    }
208
209    /// FR-6: add a `blocks` edge `blocker → blocked`; rejects a new cycle.
210    pub async fn block(&self, blocker: &Id, blocked: &Id) -> Result<(), Error> {
211        if blocker == blocked || self.blocks_reaches(blocked, blocker).await {
212            return Err(Error::Cycle(format!("{blocker} blocks {blocked}")));
213        }
214        let last = self
215            .links
216            .outgoing(blocker, LinkKind::Blocks)
217            .await
218            .last()
219            .map(|l| l.position.0);
220        self.links
221            .put(Link {
222                from: blocker.clone(),
223                to: blocked.clone(),
224                kind: LinkKind::Blocks,
225                position: Position(Position::between(last, None)),
226            })
227            .await;
228        Ok(())
229    }
230
231    /// Add/replace the `child` link `parent → id`, positioned per `anchor`.
232    pub(crate) async fn attach(
233        &self,
234        id: &Id,
235        parent: &Id,
236        anchor: Option<Anchor>,
237    ) -> Result<(), Error> {
238        // siblings excluding the one being (re)positioned
239        let sibs: Vec<_> = self
240            .children_of(parent)
241            .await
242            .into_iter()
243            .filter(|l| &l.to != id)
244            .collect();
245        let pos = match anchor {
246            None => Position::between(sibs.last().map(|l| l.position.0), None),
247            Some(Anchor::Before(x)) => {
248                let i = sibs.iter().position(|l| l.to == x);
249                match i {
250                    Some(i) => {
251                        let before = if i == 0 {
252                            None
253                        } else {
254                            Some(sibs[i - 1].position.0)
255                        };
256                        Position::between(before, Some(sibs[i].position.0))
257                    }
258                    None => Position::between(sibs.last().map(|l| l.position.0), None),
259                }
260            }
261            Some(Anchor::After(x)) => {
262                let i = sibs.iter().position(|l| l.to == x);
263                match i {
264                    Some(i) => {
265                        let after = sibs.get(i + 1).map(|l| l.position.0);
266                        Position::between(Some(sibs[i].position.0), after)
267                    }
268                    None => Position::between(sibs.last().map(|l| l.position.0), None),
269                }
270            }
271        };
272        self.links
273            .put(Link {
274                from: parent.clone(),
275                to: id.clone(),
276                kind: LinkKind::Child,
277                position: Position(pos),
278            })
279            .await;
280        Ok(())
281    }
282
283    /// Can `start` reach `target` following `blocks` edges? (cycle test)
284    async fn blocks_reaches(&self, start: &Id, target: &Id) -> bool {
285        let mut stack = vec![start.clone()];
286        let mut seen = std::collections::HashSet::new();
287        while let Some(cur) = stack.pop() {
288            if &cur == target {
289                return true;
290            }
291            if seen.insert(cur.clone()) {
292                stack.extend(
293                    self.links
294                        .outgoing(&cur, LinkKind::Blocks)
295                        .await
296                        .into_iter()
297                        .map(|l| l.to),
298                );
299            }
300        }
301        false
302    }
303}
304
305fn indent_depth(line: &str) -> usize {
306    let mut spaces = 0;
307    for c in line.chars() {
308        match c {
309            '\t' => spaces += 2,
310            ' ' => spaces += 1,
311            _ => break,
312        }
313    }
314    spaces / 2
315}