Skip to main content

todoapp_app/
service.rs

1//! `Services`: the bundle of ports the use cases run against, plus shared graph
2//! helpers and the `decide → apply → persist` mutation runner.
3//!
4//! `Services` is generic over the concrete component store `St` (spec §5
5//! capability-keyed access: `ComponentStore`'s generic `get::<C>` is not
6//! object-safe, so it can't be a `&dyn`). The graph/collection/clock/id ports
7//! stay `&dyn` — they have no generic methods.
8
9use 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/// A read-only view of a task assembled from its components — for query results,
30/// export, and as the return of a mutation. **Never** fed back to `decide`/`apply`
31/// (those work capability-keyed via the store); assembling here is one-way, so
32/// there is no partial-aggregate to reconcile. Decomposed back to components by
33/// [`Services::write_snapshot`] on import.
34#[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    /// Resolve a user-typed id or short prefix (git/jj-style abbreviation,
74    /// spec-independent — see [`todoapp_core::resolve_id_prefix`]) against every
75    /// id currently in the store. The CLI/TUI entry point for letting a human
76    /// type a short id instead of the full ULID.
77    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    /// Assemble a read-only [`TaskSnapshot`] from the task's components.
96    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    /// Decompose a snapshot back into the entity + its present components
152    /// (presence-as-capability, spec §7). Used by import.
153    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    /// Child links out of `parent`, ordered by position.
197    pub async fn children_of(&self, parent: &Id) -> Vec<todoapp_core::Link> {
198        self.links.outgoing(parent, LinkKind::Child).await
199    }
200
201    /// The structural parent of `child`, if any (single-parent tree). The
202    /// virtual-root sentinel maps to `None` — a root has no *visible* parent.
203    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    /// The raw structural parent, **including** the virtual-root sentinel — for
214    /// move/reorder, which must re-point or order the actual `child` edge a root
215    /// holds to the sentinel (unlike the public, sentinel-hiding `parent_of`).
216    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    /// Top-level tasks: the invisible root's children, ordered by position
226    /// (spec §7). Port-level via `outgoing(ROOT, Child)` — indexed in Turso,
227    /// no whole-store scan.
228    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    /// Derived `blocked` (spec §8): some incoming `blocks` edge whose blocker
237    /// task is not `done`.
238    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    /// All descendants of `id` via `child` links (excludes `id`).
253    pub async fn descendants(&self, id: &Id) -> HashSet<Id> {
254        todoapp_core::descendants(self.links, id).await
255    }
256
257    /// Run a task-local command through `decide → apply → persist` (spec §5a),
258    /// capability-keyed: `decide`/`apply` read and write components via the store.
259    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}