Skip to main content

todoapp_core/
command.rs

1//! The decider pattern (spec §5a): `decide` runs a command through an ordered
2//! list of guards, then emits events; `apply` writes those events back as
3//! components. Both are **async over a [`ComponentStore`]** (spec §5, superseding
4//! `[DECISION]`): a guard reads only the capabilities it needs via `get`, and
5//! `apply` touches only what changed via `set`/`remove` — no whole-task aggregate.
6//!
7//! Scope: the *task-local* lifecycle commands live here. Structural commands
8//! (move, link) need the graph and so are validated in `todoapp-app` — that's where
9//! the tree/DAG live. Both still flow through guard-style checks (FR-26).
10
11use std::collections::BTreeMap;
12
13use crate::model::{
14    Archived, Assignment, Assignments, Attachment, Attachments, Estimate, Id, IssueRef, Notes,
15    Recurrence, Schedule, Status, Tags, TimeLog, TimeSpent, Title, Workspace,
16};
17use crate::ports::ComponentStore;
18use crate::temporal::{Date, Due, Duration};
19
20/// A refused command, with a human/agent-readable reason (spec §5a).
21#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)]
22#[display("{_0}")]
23pub struct Denied(#[error(not(source))] pub String);
24
25/// Intent to mutate one task. (`Create` is [`TaskState::new`], not a command.)
26#[derive(Debug, Clone, PartialEq)]
27pub enum Command {
28    SetTitle(String),
29    SetNotes(Option<String>),
30    SetStatus(Status),
31    SetSchedule(Option<Due>),
32    SetEstimate(Option<Duration>),
33    AddTimeSpent(Duration),
34    AddTag(String),
35    RemoveTag(String),
36    Assign(Id),
37    Unassign(Id),
38    Claim(Id),
39    SetRecurrence(Option<Recurrence>),
40    SetIssueRef(Option<IssueRef>),
41    SetWorkspace(Option<Workspace>),
42    SetTimeLog(BTreeMap<Date, Duration>),
43    SetArchived(bool),
44    AddAttachment(Attachment),
45    RemoveAttachment(Id),
46}
47
48/// The decided result of a command, folded by [`apply`].
49#[derive(Debug, Clone, PartialEq)]
50pub enum Event {
51    TitleSet(String),
52    NotesSet(Option<String>),
53    StatusSet(Status),
54    ScheduleSet(Option<Due>),
55    EstimateSet(Option<Duration>),
56    TimeSpentAdded(Duration),
57    TagAdded(String),
58    TagRemoved(String),
59    Assigned(Id),
60    Unassigned(Id),
61    /// Sets `wip` and marks (or adds) the claimer's assignment as claimed.
62    Claimed(Id),
63    RecurrenceSet(Option<Recurrence>),
64    IssueRefSet(Option<IssueRef>),
65    WorkspaceSet(Option<Workspace>),
66    TimeLogSet(BTreeMap<Date, Duration>),
67    ArchivedSet(bool),
68    AttachmentAdded(Attachment),
69    AttachmentRemoved(Id),
70}
71
72/// Facts a guard needs beyond the task itself. Just the derived `blocked` flag
73/// for now (spec §8 start-gate); grow as systems need more.
74#[derive(Debug, Clone, Copy, Default)]
75pub struct DecideCtx {
76    pub blocked: bool,
77}
78
79/// Run `cmd` through the ordered guards (first denial wins, spec §13 Q2 default),
80/// reading current state from `store`. On allow, emit the events. Async: guards
81/// `get` only the capabilities they inspect.
82pub async fn decide<St: ComponentStore>(
83    store: &St,
84    id: &Id,
85    cmd: &Command,
86    ctx: &DecideCtx,
87) -> Result<Vec<Event>, Denied> {
88    if let Some(d) = g_blocked_start(cmd, ctx) {
89        return Err(d);
90    }
91    if let Some(d) = g_claim_rules(store, id, cmd).await {
92        return Err(d);
93    }
94    Ok(events_for(store, id, cmd).await)
95}
96
97/// Write an event back as components (spec §7 presence-as-capability): a
98/// collection that becomes empty is `remove`d, not stored empty.
99pub async fn apply<St: ComponentStore>(store: &St, id: &Id, event: &Event) {
100    match event {
101        Event::TitleSet(t) => store.set(id, Title(t.clone())).await,
102        Event::NotesSet(Some(n)) => store.set(id, Notes(n.clone())).await,
103        Event::NotesSet(None) => store.remove::<Notes>(id).await,
104        // A recurring task doesn't stay `done`: it resets in place (spec
105        // decision — no per-occurrence spawning). No-op if it has no
106        // `Schedule` to advance from.
107        Event::StatusSet(Status::Done) => {
108            match (
109                store.get::<Recurrence>(id).await,
110                store.get::<Schedule>(id).await,
111            ) {
112                (Some(rec), Some(sched)) => {
113                    store.set(id, Schedule(rec.next_due(sched.0))).await;
114                    store.set(id, Status::Todo).await;
115                }
116                _ => store.set(id, Status::Done).await,
117            }
118        }
119        Event::StatusSet(s) => store.set(id, *s).await,
120        Event::ScheduleSet(Some(d)) => store.set(id, Schedule(*d)).await,
121        Event::ScheduleSet(None) => store.remove::<Schedule>(id).await,
122        Event::EstimateSet(Some(e)) => store.set(id, Estimate(*e)).await,
123        Event::EstimateSet(None) => store.remove::<Estimate>(id).await,
124        Event::TimeSpentAdded(m) => {
125            let cur = store
126                .get::<TimeSpent>(id)
127                .await
128                .map_or(Duration::ZERO, |t| t.0);
129            store.set(id, TimeSpent(cur + *m)).await;
130        }
131        Event::TagAdded(t) => {
132            let mut tags = store.get::<Tags>(id).await.unwrap_or_default();
133            tags.0.insert(t.clone());
134            store.set(id, tags).await;
135        }
136        Event::TagRemoved(t) => {
137            let mut tags = store.get::<Tags>(id).await.unwrap_or_default();
138            tags.0.remove(t);
139            detach_if_empty(store, id, tags.0.is_empty(), tags).await;
140        }
141        Event::Assigned(a) => {
142            let mut asg = store.get::<Assignments>(id).await.unwrap_or_default();
143            asg.0.push(Assignment {
144                actor: a.clone(),
145                claimed: false,
146            });
147            store.set(id, asg).await;
148        }
149        Event::Unassigned(a) => {
150            let mut asg = store.get::<Assignments>(id).await.unwrap_or_default();
151            asg.0.retain(|x| &x.actor != a);
152            detach_if_empty(store, id, asg.0.is_empty(), asg).await;
153        }
154        Event::Claimed(a) => {
155            store.set(id, Status::Wip).await;
156            let mut asg = store.get::<Assignments>(id).await.unwrap_or_default();
157            match asg.0.iter_mut().find(|x| &x.actor == a) {
158                Some(x) => x.claimed = true,
159                None => asg.0.push(Assignment {
160                    actor: a.clone(),
161                    claimed: true,
162                }),
163            }
164            store.set(id, asg).await;
165        }
166        Event::RecurrenceSet(Some(r)) => store.set(id, r.clone()).await,
167        Event::RecurrenceSet(None) => store.remove::<Recurrence>(id).await,
168        Event::IssueRefSet(Some(r)) => store.set(id, r.clone()).await,
169        Event::IssueRefSet(None) => store.remove::<IssueRef>(id).await,
170        Event::WorkspaceSet(Some(w)) => store.set(id, w.clone()).await,
171        Event::WorkspaceSet(None) => store.remove::<Workspace>(id).await,
172        Event::TimeLogSet(m) => {
173            if m.is_empty() {
174                store.remove::<TimeLog>(id).await;
175                store.remove::<TimeSpent>(id).await;
176            } else {
177                let total: Duration = m.values().copied().sum();
178                store.set(id, TimeLog(m.clone())).await;
179                store.set(id, TimeSpent(total)).await;
180            }
181        }
182        Event::ArchivedSet(true) => store.set(id, Archived).await,
183        Event::ArchivedSet(false) => store.remove::<Archived>(id).await,
184        Event::AttachmentAdded(a) => {
185            let mut atts = store.get::<Attachments>(id).await.unwrap_or_default();
186            atts.0.retain(|x| x.id != a.id);
187            atts.0.push(a.clone());
188            store.set(id, atts).await;
189        }
190        Event::AttachmentRemoved(aid) => {
191            let mut atts = store.get::<Attachments>(id).await.unwrap_or_default();
192            atts.0.retain(|x| &x.id != aid);
193            detach_if_empty(store, id, atts.0.is_empty(), atts).await;
194        }
195    }
196}
197
198/// `set` a collection component, or `remove` it when it just became empty.
199async fn detach_if_empty<St: ComponentStore, C: crate::model::Component>(
200    store: &St,
201    id: &Id,
202    empty: bool,
203    value: C,
204) {
205    if empty {
206        store.remove::<C>(id).await;
207    } else {
208        store.set(id, value).await;
209    }
210}
211
212/// Map an allowed command to its events. No-ops (idempotent re-sets) yield `[]`,
213/// read from the store's current values.
214async fn events_for<St: ComponentStore>(store: &St, id: &Id, cmd: &Command) -> Vec<Event> {
215    match cmd {
216        Command::SetTitle(t) => {
217            let cur = store.get::<Title>(id).await.map(|x| x.0);
218            no_op_or(cur.as_ref() == Some(t), Event::TitleSet(t.clone()))
219        }
220        Command::SetNotes(n) => {
221            let cur = store.get::<Notes>(id).await.map(|x| x.0);
222            no_op_or(&cur == n, Event::NotesSet(n.clone()))
223        }
224        Command::SetStatus(s) => {
225            let cur = store.get::<Status>(id).await;
226            no_op_or(cur.as_ref() == Some(s), Event::StatusSet(*s))
227        }
228        Command::SetSchedule(d) => {
229            let cur = store.get::<Schedule>(id).await.map(|x| x.0);
230            no_op_or(&cur == d, Event::ScheduleSet(*d))
231        }
232        Command::SetEstimate(e) => {
233            let cur = store.get::<Estimate>(id).await.map(|x| x.0);
234            no_op_or(&cur == e, Event::EstimateSet(*e))
235        }
236        Command::AddTimeSpent(m) if *m == Duration::ZERO => vec![],
237        Command::AddTimeSpent(m) => vec![Event::TimeSpentAdded(*m)],
238        Command::AddTag(t) => {
239            let has = store.get::<Tags>(id).await.is_some_and(|x| x.0.contains(t));
240            no_op_or(has, Event::TagAdded(t.clone()))
241        }
242        Command::RemoveTag(t) => {
243            let has = store.get::<Tags>(id).await.is_some_and(|x| x.0.contains(t));
244            no_op_or(!has, Event::TagRemoved(t.clone()))
245        }
246        Command::Assign(a) => {
247            let has = assigned(store, id, a).await;
248            no_op_or(has, Event::Assigned(a.clone()))
249        }
250        Command::Unassign(a) => {
251            let has = assigned(store, id, a).await;
252            no_op_or(!has, Event::Unassigned(a.clone()))
253        }
254        Command::Claim(a) => vec![Event::Claimed(a.clone())],
255        Command::SetRecurrence(r) => {
256            let cur = store.get::<Recurrence>(id).await;
257            no_op_or(&cur == r, Event::RecurrenceSet(r.clone()))
258        }
259        Command::SetIssueRef(r) => {
260            let cur = store.get::<IssueRef>(id).await;
261            no_op_or(&cur == r, Event::IssueRefSet(r.clone()))
262        }
263        Command::SetWorkspace(w) => {
264            let cur = store.get::<Workspace>(id).await;
265            no_op_or(&cur == w, Event::WorkspaceSet(w.clone()))
266        }
267        Command::SetTimeLog(m) => {
268            let cur = store.get::<TimeLog>(id).await.unwrap_or_default();
269            no_op_or(&cur.0 == m, Event::TimeLogSet(m.clone()))
270        }
271        Command::SetArchived(a) => {
272            let cur = store.get::<Archived>(id).await.is_some();
273            no_op_or(cur == *a, Event::ArchivedSet(*a))
274        }
275        Command::AddAttachment(a) => vec![Event::AttachmentAdded(a.clone())],
276        Command::RemoveAttachment(aid) => vec![Event::AttachmentRemoved(aid.clone())],
277    }
278}
279
280/// `[]` when the command is a no-op, else the single resulting event.
281fn no_op_or(is_no_op: bool, event: Event) -> Vec<Event> {
282    if is_no_op { vec![] } else { vec![event] }
283}
284
285async fn assigned<St: ComponentStore>(store: &St, id: &Id, actor: &Id) -> bool {
286    store
287        .get::<Assignments>(id)
288        .await
289        .is_some_and(|a| a.0.iter().any(|x| &x.actor == actor))
290}
291
292// ---- guards ----------------------------------------------------------------
293
294/// `blocks` system: cannot start (`→wip`, via SetStatus or Claim) while blocked.
295fn g_blocked_start(cmd: &Command, ctx: &DecideCtx) -> Option<Denied> {
296    let starting = matches!(cmd, Command::SetStatus(Status::Wip) | Command::Claim(_));
297    if ctx.blocked && starting {
298        return Some(Denied("blocked: a blocker is not done".into()));
299    }
300    None
301}
302
303/// `Assignment` capability: claim only from `todo`; if assignees exist, only a
304/// listed one may claim (FR-11, §8).
305async fn g_claim_rules<St: ComponentStore>(store: &St, id: &Id, cmd: &Command) -> Option<Denied> {
306    if let Command::Claim(actor) = cmd {
307        if store.get::<Status>(id).await != Some(Status::Todo) {
308            return Some(Denied("claim allowed only from todo".into()));
309        }
310        let asg = store.get::<Assignments>(id).await.unwrap_or_default();
311        if !asg.0.is_empty() && !asg.0.iter().any(|a| &a.actor == actor) {
312            return Some(Denied("claim restricted to assignees".into()));
313        }
314    }
315    None
316}