1use std::collections::{BTreeMap, BTreeSet, HashSet};
10
11use serde::{Deserialize, Serialize};
12use todoapp_core::{
13 Archived, Assignment, Assignments, Attachment, Attachments, BlobStore, Clock,
14 CollectionRepository, Command, ComponentStore, Date, DecideCtx, Denied, Due, Duration,
15 Estimate, Id, IdGenerator, IssueRef, LinkKind, LinkRepository, Notes, QueryEngine, Recurrence,
16 Schedule, Status, Tags, TaskEntityStore, TimeLog, TimeSpent, Timestamp, Title, apply, decide,
17};
18
19pub struct Services<'a, St> {
20 pub store: &'a St,
21 pub links: &'a dyn LinkRepository,
22 pub collections: &'a dyn CollectionRepository,
23 pub query: &'a dyn QueryEngine,
24 pub clock: &'a dyn Clock,
25 pub ids: &'a dyn IdGenerator,
26 pub blobs: &'a dyn BlobStore,
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct TaskSnapshot {
36 pub id: Id,
37 pub title: String,
38 pub status: Status,
39 pub notes: Option<String>,
40 pub due_date: Option<Due>,
41 pub eta_minutes: Option<Duration>,
42 pub time_spent_minutes: Duration,
43 pub tags: BTreeSet<String>,
44 pub assignments: Vec<Assignment>,
45 pub recurrence: Option<Recurrence>,
46 pub issue_ref: Option<IssueRef>,
47 pub time_log: BTreeMap<Date, Duration>,
48 pub archived: bool,
49 pub attachments: Vec<Attachment>,
50 pub created_at: Timestamp,
51 pub updated_at: Timestamp,
52}
53
54#[derive(Debug, derive_more::Display, derive_more::Error, derive_more::From, PartialEq)]
55pub enum Error {
56 #[from(skip)]
57 #[display("task not found: {_0}")]
58 NotFound(#[error(not(source))] Id),
59 #[display("denied: {_0}")]
60 Denied(Denied),
61 #[from(skip)]
62 #[display("would create a cycle: {_0}")]
63 Cycle(#[error(not(source))] String),
64 #[from(skip)]
65 #[display("import error: {_0}")]
66 Import(#[error(not(source))] String),
67 #[from(skip)]
68 #[display("ambiguous id: {_0}")]
69 AmbiguousId(#[error(not(source))] String),
70}
71
72impl<'a, St: ComponentStore + TaskEntityStore> Services<'a, St> {
73 pub async fn resolve_id(&self, typed: &str) -> Result<Id, Error> {
78 let ids = self.store.all().await;
79 match todoapp_core::resolve_id_prefix(&ids, typed) {
80 todoapp_core::ResolvedId::Found(id) => Ok(id),
81 todoapp_core::ResolvedId::NotFound => Err(Error::NotFound(Id::new(typed))),
82 todoapp_core::ResolvedId::Ambiguous(matches) => {
83 let candidates = matches
84 .iter()
85 .map(Id::as_str)
86 .collect::<Vec<_>>()
87 .join(", ");
88 Err(Error::AmbiguousId(format!(
89 "{typed:?} matches {candidates}"
90 )))
91 }
92 }
93 }
94
95 pub async fn snapshot(&self, id: &Id) -> Result<TaskSnapshot, Error> {
97 let (created_at, updated_at) = self
98 .store
99 .meta(id)
100 .await
101 .ok_or_else(|| Error::NotFound(id.clone()))?;
102 Ok(TaskSnapshot {
103 id: id.clone(),
104 title: self
105 .store
106 .get::<Title>(id)
107 .await
108 .map(|t| t.0)
109 .unwrap_or_default(),
110 status: self.store.get::<Status>(id).await.unwrap_or(Status::Draft),
111 notes: self.store.get::<Notes>(id).await.map(|n| n.0),
112 due_date: self.store.get::<Schedule>(id).await.map(|s| s.0),
113 eta_minutes: self.store.get::<Estimate>(id).await.map(|e| e.0),
114 time_spent_minutes: self
115 .store
116 .get::<TimeSpent>(id)
117 .await
118 .map_or(Duration::ZERO, |t| t.0),
119 tags: self
120 .store
121 .get::<Tags>(id)
122 .await
123 .map(|t| t.0)
124 .unwrap_or_default(),
125 assignments: self
126 .store
127 .get::<Assignments>(id)
128 .await
129 .map(|a| a.0)
130 .unwrap_or_default(),
131 recurrence: self.store.get::<Recurrence>(id).await,
132 issue_ref: self.store.get::<IssueRef>(id).await,
133 time_log: self
134 .store
135 .get::<TimeLog>(id)
136 .await
137 .map(|t| t.0)
138 .unwrap_or_default(),
139 archived: self.store.get::<Archived>(id).await.is_some(),
140 attachments: self
141 .store
142 .get::<Attachments>(id)
143 .await
144 .map(|a| a.0)
145 .unwrap_or_default(),
146 created_at,
147 updated_at,
148 })
149 }
150
151 pub(crate) async fn write_snapshot(&self, t: &TaskSnapshot) {
154 self.store.create(&t.id, t.created_at, t.updated_at).await;
155 self.store.set(&t.id, Title(t.title.clone())).await;
156 self.store.set(&t.id, t.status).await;
157 if let Some(n) = &t.notes {
158 self.store.set(&t.id, Notes(n.clone())).await;
159 }
160 if let Some(d) = t.due_date {
161 self.store.set(&t.id, Schedule(d)).await;
162 }
163 if let Some(e) = t.eta_minutes {
164 self.store.set(&t.id, Estimate(e)).await;
165 }
166 if t.time_spent_minutes != Duration::ZERO {
167 self.store.set(&t.id, TimeSpent(t.time_spent_minutes)).await;
168 }
169 if !t.tags.is_empty() {
170 self.store.set(&t.id, Tags(t.tags.clone())).await;
171 }
172 if !t.assignments.is_empty() {
173 self.store
174 .set(&t.id, Assignments(t.assignments.clone()))
175 .await;
176 }
177 if let Some(r) = &t.recurrence {
178 self.store.set(&t.id, r.clone()).await;
179 }
180 if let Some(r) = &t.issue_ref {
181 self.store.set(&t.id, r.clone()).await;
182 }
183 if !t.time_log.is_empty() {
184 self.store.set(&t.id, TimeLog(t.time_log.clone())).await;
185 }
186 if t.archived {
187 self.store.set(&t.id, Archived).await;
188 }
189 if !t.attachments.is_empty() {
190 self.store
191 .set(&t.id, Attachments(t.attachments.clone()))
192 .await;
193 }
194 }
195
196 pub async fn children_of(&self, parent: &Id) -> Vec<todoapp_core::Link> {
198 self.links.outgoing(parent, LinkKind::Child).await
199 }
200
201 pub async fn parent_of(&self, child: &Id) -> Option<Id> {
204 self.links
205 .incoming(child, LinkKind::Child)
206 .await
207 .into_iter()
208 .next()
209 .map(|l| l.from)
210 .filter(|p| !p.is_root())
211 }
212
213 pub(crate) async fn raw_parent_of(&self, child: &Id) -> Option<Id> {
217 self.links
218 .incoming(child, LinkKind::Child)
219 .await
220 .into_iter()
221 .next()
222 .map(|l| l.from)
223 }
224
225 pub async fn roots(&self) -> Vec<Id> {
229 self.children_of(&Id::root())
230 .await
231 .into_iter()
232 .map(|l| l.to)
233 .collect()
234 }
235
236 pub async fn is_blocked(&self, id: &Id) -> bool {
239 for l in self.links.incoming(id, LinkKind::Blocks).await {
240 if self
241 .store
242 .get::<Status>(&l.from)
243 .await
244 .is_some_and(|s| s != Status::Done)
245 {
246 return true;
247 }
248 }
249 false
250 }
251
252 pub async fn descendants(&self, id: &Id) -> HashSet<Id> {
254 todoapp_core::descendants(self.links, id).await
255 }
256
257 pub async fn run(&self, id: &Id, cmd: Command) -> Result<TaskSnapshot, Error> {
260 if self.store.meta(id).await.is_none() {
261 return Err(Error::NotFound(id.clone()));
262 }
263 let ctx = DecideCtx {
264 blocked: self.is_blocked(id).await,
265 };
266 let events = decide(self.store, id, &cmd, &ctx).await?;
267 for e in &events {
268 apply(self.store, id, e).await;
269 }
270 if !events.is_empty() {
271 self.store.touch(id, self.clock.now()).await;
272 }
273 self.snapshot(id).await
274 }
275}