1use 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#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)]
22#[display("{_0}")]
23pub struct Denied(#[error(not(source))] pub String);
24
25#[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#[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 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#[derive(Debug, Clone, Copy, Default)]
73pub struct DecideCtx {
74 pub blocked: bool,
75}
76
77pub 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
95pub 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 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
194async 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
208async 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
272fn 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
284fn 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
295async 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}