use std::collections::{BTreeMap, BTreeSet, HashSet};
use serde::{Deserialize, Serialize};
use todoapp_core::{
Archived, Assignment, Assignments, Attachment, Attachments, BlobStore, Clock,
CollectionRepository, Command, ComponentStore, Date, DecideCtx, Denied, Due, Duration,
Estimate, Id, IdGenerator, IssueRef, LinkKind, LinkRepository, Notes, QueryEngine, Recurrence,
Schedule, Status, Tags, TaskEntityStore, TimeLog, TimeSpent, Timestamp, Title, apply, decide,
};
pub struct Services<'a, St> {
pub store: &'a St,
pub links: &'a dyn LinkRepository,
pub collections: &'a dyn CollectionRepository,
pub query: &'a dyn QueryEngine,
pub clock: &'a dyn Clock,
pub ids: &'a dyn IdGenerator,
pub blobs: &'a dyn BlobStore,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskSnapshot {
pub id: Id,
pub title: String,
pub status: Status,
pub notes: Option<String>,
pub due_date: Option<Due>,
pub eta_minutes: Option<Duration>,
pub time_spent_minutes: Duration,
pub tags: BTreeSet<String>,
pub assignments: Vec<Assignment>,
pub recurrence: Option<Recurrence>,
pub issue_ref: Option<IssueRef>,
pub time_log: BTreeMap<Date, Duration>,
pub archived: bool,
pub attachments: Vec<Attachment>,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
#[derive(Debug, derive_more::Display, derive_more::Error, derive_more::From, PartialEq)]
pub enum Error {
#[from(skip)]
#[display("task not found: {_0}")]
NotFound(#[error(not(source))] Id),
#[display("denied: {_0}")]
Denied(Denied),
#[from(skip)]
#[display("would create a cycle: {_0}")]
Cycle(#[error(not(source))] String),
#[from(skip)]
#[display("import error: {_0}")]
Import(#[error(not(source))] String),
#[from(skip)]
#[display("ambiguous id: {_0}")]
AmbiguousId(#[error(not(source))] String),
}
impl<'a, St: ComponentStore + TaskEntityStore> Services<'a, St> {
pub async fn resolve_id(&self, typed: &str) -> Result<Id, Error> {
let ids = self.store.all().await;
match todoapp_core::resolve_id_prefix(&ids, typed) {
todoapp_core::ResolvedId::Found(id) => Ok(id),
todoapp_core::ResolvedId::NotFound => Err(Error::NotFound(Id::new(typed))),
todoapp_core::ResolvedId::Ambiguous(matches) => {
let candidates = matches
.iter()
.map(Id::as_str)
.collect::<Vec<_>>()
.join(", ");
Err(Error::AmbiguousId(format!(
"{typed:?} matches {candidates}"
)))
}
}
}
pub async fn snapshot(&self, id: &Id) -> Result<TaskSnapshot, Error> {
let (created_at, updated_at) = self
.store
.meta(id)
.await
.ok_or_else(|| Error::NotFound(id.clone()))?;
Ok(TaskSnapshot {
id: id.clone(),
title: self
.store
.get::<Title>(id)
.await
.map(|t| t.0)
.unwrap_or_default(),
status: self.store.get::<Status>(id).await.unwrap_or(Status::Draft),
notes: self.store.get::<Notes>(id).await.map(|n| n.0),
due_date: self.store.get::<Schedule>(id).await.map(|s| s.0),
eta_minutes: self.store.get::<Estimate>(id).await.map(|e| e.0),
time_spent_minutes: self
.store
.get::<TimeSpent>(id)
.await
.map_or(Duration::ZERO, |t| t.0),
tags: self
.store
.get::<Tags>(id)
.await
.map(|t| t.0)
.unwrap_or_default(),
assignments: self
.store
.get::<Assignments>(id)
.await
.map(|a| a.0)
.unwrap_or_default(),
recurrence: self.store.get::<Recurrence>(id).await,
issue_ref: self.store.get::<IssueRef>(id).await,
time_log: self
.store
.get::<TimeLog>(id)
.await
.map(|t| t.0)
.unwrap_or_default(),
archived: self.store.get::<Archived>(id).await.is_some(),
attachments: self
.store
.get::<Attachments>(id)
.await
.map(|a| a.0)
.unwrap_or_default(),
created_at,
updated_at,
})
}
pub(crate) async fn write_snapshot(&self, t: &TaskSnapshot) {
self.store.create(&t.id, t.created_at, t.updated_at).await;
self.store.set(&t.id, Title(t.title.clone())).await;
self.store.set(&t.id, t.status).await;
if let Some(n) = &t.notes {
self.store.set(&t.id, Notes(n.clone())).await;
}
if let Some(d) = t.due_date {
self.store.set(&t.id, Schedule(d)).await;
}
if let Some(e) = t.eta_minutes {
self.store.set(&t.id, Estimate(e)).await;
}
if t.time_spent_minutes != Duration::ZERO {
self.store.set(&t.id, TimeSpent(t.time_spent_minutes)).await;
}
if !t.tags.is_empty() {
self.store.set(&t.id, Tags(t.tags.clone())).await;
}
if !t.assignments.is_empty() {
self.store
.set(&t.id, Assignments(t.assignments.clone()))
.await;
}
if let Some(r) = &t.recurrence {
self.store.set(&t.id, r.clone()).await;
}
if let Some(r) = &t.issue_ref {
self.store.set(&t.id, r.clone()).await;
}
if !t.time_log.is_empty() {
self.store.set(&t.id, TimeLog(t.time_log.clone())).await;
}
if t.archived {
self.store.set(&t.id, Archived).await;
}
if !t.attachments.is_empty() {
self.store
.set(&t.id, Attachments(t.attachments.clone()))
.await;
}
}
pub async fn children_of(&self, parent: &Id) -> Vec<todoapp_core::Link> {
self.links.outgoing(parent, LinkKind::Child).await
}
pub async fn parent_of(&self, child: &Id) -> Option<Id> {
self.links
.incoming(child, LinkKind::Child)
.await
.into_iter()
.next()
.map(|l| l.from)
.filter(|p| !p.is_root())
}
pub(crate) async fn raw_parent_of(&self, child: &Id) -> Option<Id> {
self.links
.incoming(child, LinkKind::Child)
.await
.into_iter()
.next()
.map(|l| l.from)
}
pub async fn roots(&self) -> Vec<Id> {
self.children_of(&Id::root())
.await
.into_iter()
.map(|l| l.to)
.collect()
}
pub async fn is_blocked(&self, id: &Id) -> bool {
for l in self.links.incoming(id, LinkKind::Blocks).await {
if self
.store
.get::<Status>(&l.from)
.await
.is_some_and(|s| s != Status::Done)
{
return true;
}
}
false
}
pub async fn descendants(&self, id: &Id) -> HashSet<Id> {
todoapp_core::descendants(self.links, id).await
}
pub async fn run(&self, id: &Id, cmd: Command) -> Result<TaskSnapshot, Error> {
if self.store.meta(id).await.is_none() {
return Err(Error::NotFound(id.clone()));
}
let ctx = DecideCtx {
blocked: self.is_blocked(id).await,
};
let events = decide(self.store, id, &cmd, &ctx).await?;
for e in &events {
apply(self.store, id, e).await;
}
if !events.is_empty() {
self.store.touch(id, self.clock.now()).await;
}
self.snapshot(id).await
}
}