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, Workspace,
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 SetWorkspace(Option<Workspace>),
42 SetTimeLog(BTreeMap<Date, Duration>),
43 SetArchived(bool),
44 AddAttachment(Attachment),
45 RemoveAttachment(Id),
46}
47
48#[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 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#[derive(Debug, Clone, Copy, Default)]
75pub struct DecideCtx {
76 pub blocked: bool,
77}
78
79pub 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
97pub 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 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
198async 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
212async 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
280fn 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
292fn 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
303async 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}