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