Skip to main content

stint_core/
service.rs

1//! High-level business logic for Stint operations.
2//!
3//! The service layer wraps the `Storage` trait with validation and business rules,
4//! keeping CLI handlers thin.
5
6use time::OffsetDateTime;
7
8use crate::error::StintError;
9use crate::models::entry::{EntryFilter, EntrySource, TimeEntry};
10use crate::models::project::{Project, ProjectStatus};
11use crate::models::types::{EntryId, ProjectId};
12use crate::storage::Storage;
13
14/// High-level operations for Stint, wrapping a storage backend.
15pub struct StintService<S: Storage> {
16    storage: S,
17}
18
19impl<S: Storage> StintService<S> {
20    /// Creates a new service wrapping the given storage backend.
21    pub fn new(storage: S) -> Self {
22        Self { storage }
23    }
24
25    /// Returns a reference to the underlying storage.
26    pub fn storage(&self) -> &S {
27        &self.storage
28    }
29
30    /// Looks up an active project by name, returning an error if not found or archived.
31    fn resolve_active_project(&self, name: &str) -> Result<Project, StintError> {
32        let project = self
33            .storage
34            .get_project_by_name(name)?
35            .ok_or_else(|| StintError::InvalidInput(format!("project '{name}' not found")))?;
36
37        if project.status == ProjectStatus::Archived {
38            return Err(StintError::ProjectNotActive(name.to_string()));
39        }
40
41        Ok(project)
42    }
43
44    /// Starts a timer for the named project.
45    ///
46    /// Fails if a timer is already running or the project is not found/archived.
47    pub fn start_timer(&self, project_name: &str) -> Result<(TimeEntry, Project), StintError> {
48        let project = self.resolve_active_project(project_name)?;
49
50        // Check for any running timer across all projects
51        if let Some(running) = self.storage.get_any_running_entry()? {
52            let running_project = self.storage.get_project(&running.project_id)?;
53            let name = running_project
54                .map(|p| p.name)
55                .unwrap_or_else(|| "unknown".to_string());
56            return Err(StintError::TimerAlreadyRunning(name));
57        }
58
59        let now = OffsetDateTime::now_utc();
60        let entry = TimeEntry {
61            id: EntryId::new(),
62            project_id: project.id.clone(),
63            session_id: None,
64            start: now,
65            end: None,
66            duration_secs: None,
67            source: EntrySource::Manual,
68            notes: None,
69            tags: vec![],
70            created_at: now,
71            updated_at: now,
72        };
73
74        self.storage.create_entry(&entry)?;
75        Ok((entry, project))
76    }
77
78    /// Stops the currently running timer.
79    ///
80    /// Fails if no timer is running.
81    pub fn stop_timer(&self) -> Result<(TimeEntry, Project), StintError> {
82        let mut entry = self
83            .storage
84            .get_any_running_entry()?
85            .ok_or(StintError::NoRunningTimer)?;
86
87        let now = OffsetDateTime::now_utc();
88        entry.end = Some(now);
89        entry.duration_secs = Some((now - entry.start).whole_seconds());
90        entry.updated_at = now;
91
92        self.storage.update_entry(&entry)?;
93
94        let project = self
95            .storage
96            .get_project(&entry.project_id)?
97            .ok_or_else(|| StintError::InvalidInput("project not found".to_string()))?;
98
99        Ok((entry, project))
100    }
101
102    /// Adds a completed time entry retroactively.
103    ///
104    /// If no date is provided, uses today. The entry is created with `source: Added`.
105    pub fn add_time(
106        &self,
107        project_name: &str,
108        duration_secs: i64,
109        date: Option<OffsetDateTime>,
110        notes: Option<&str>,
111    ) -> Result<(TimeEntry, Project), StintError> {
112        if duration_secs <= 0 {
113            return Err(StintError::InvalidInput(
114                "duration must be greater than zero".to_string(),
115            ));
116        }
117
118        let project = self.resolve_active_project(project_name)?;
119
120        let start = date.unwrap_or_else(|| {
121            let now = OffsetDateTime::now_utc();
122            now.date().midnight().assume_utc()
123        });
124        let end = start + time::Duration::seconds(duration_secs);
125        let now = OffsetDateTime::now_utc();
126
127        let entry = TimeEntry {
128            id: EntryId::new(),
129            project_id: project.id.clone(),
130            session_id: None,
131            start,
132            end: Some(end),
133            duration_secs: Some(duration_secs),
134            source: EntrySource::Added,
135            notes: notes.map(|s| s.to_string()),
136            tags: vec![],
137            created_at: now,
138            updated_at: now,
139        };
140
141        self.storage.create_entry(&entry)?;
142        Ok((entry, project))
143    }
144
145    /// Returns the currently running timer and its project, if any.
146    pub fn get_status(&self) -> Result<Option<(TimeEntry, Project)>, StintError> {
147        let entry = self.storage.get_any_running_entry()?;
148        match entry {
149            Some(e) => {
150                let project = self
151                    .storage
152                    .get_project(&e.project_id)?
153                    .ok_or_else(|| StintError::InvalidInput("project not found".to_string()))?;
154                Ok(Some((e, project)))
155            }
156            None => Ok(None),
157        }
158    }
159
160    /// Archives a project, hiding it from default listings.
161    ///
162    /// Stops any running timer for the project first.
163    pub fn archive_project(&self, name: &str) -> Result<Project, StintError> {
164        let mut project = self
165            .storage
166            .get_project_by_name(name)?
167            .ok_or_else(|| StintError::InvalidInput(format!("project '{name}' not found")))?;
168
169        if project.status == ProjectStatus::Archived {
170            return Err(StintError::InvalidInput(format!(
171                "project '{name}' is already archived"
172            )));
173        }
174
175        // Stop any running timer for this project
176        if let Some(mut entry) = self.storage.get_running_entry(&project.id)? {
177            let now = OffsetDateTime::now_utc();
178            entry.end = Some(now);
179            entry.duration_secs = Some((now - entry.start).whole_seconds());
180            entry.updated_at = now;
181            self.storage.update_entry(&entry)?;
182        }
183
184        project.status = ProjectStatus::Archived;
185        project.updated_at = OffsetDateTime::now_utc();
186        self.storage.update_project(&project)?;
187
188        Ok(project)
189    }
190
191    /// Deletes a project and all its entries.
192    pub fn delete_project(&self, name: &str) -> Result<String, StintError> {
193        let project = self
194            .storage
195            .get_project_by_name(name)?
196            .ok_or_else(|| StintError::InvalidInput(format!("project '{name}' not found")))?;
197
198        self.storage.delete_project(&project.id)?;
199        Ok(project.name)
200    }
201
202    /// Lists entries matching the given filter, enriched with project data.
203    pub fn get_entries(
204        &self,
205        filter: &EntryFilter,
206    ) -> Result<Vec<(TimeEntry, Project)>, StintError> {
207        let entries = self.storage.list_entries(filter)?;
208        let mut results = Vec::with_capacity(entries.len());
209
210        // Cache projects to avoid repeated lookups
211        let mut project_cache: std::collections::HashMap<String, Project> =
212            std::collections::HashMap::new();
213
214        for entry in entries {
215            let pid_str = entry.project_id.as_str().to_owned();
216            let project = if let Some(cached) = project_cache.get(&pid_str) {
217                cached.clone()
218            } else {
219                let p = self
220                    .storage
221                    .get_project(&entry.project_id)?
222                    .ok_or_else(|| {
223                        StintError::InvalidInput(format!(
224                            "project not found for entry {}",
225                            entry.id
226                        ))
227                    })?;
228                project_cache.insert(pid_str, p.clone());
229                p
230            };
231
232            results.push((entry, project));
233        }
234
235        Ok(results)
236    }
237
238    /// Returns the most recent time entry with its project.
239    pub fn get_last_entry(&self) -> Result<Option<(TimeEntry, Project)>, StintError> {
240        match self.storage.get_last_entry()? {
241            Some(entry) => {
242                let project = self
243                    .storage
244                    .get_project(&entry.project_id)?
245                    .ok_or_else(|| {
246                        StintError::InvalidInput("project not found for entry".to_string())
247                    })?;
248                Ok(Some((entry, project)))
249            }
250            None => Ok(None),
251        }
252    }
253
254    /// Deletes a time entry by ID.
255    pub fn delete_entry(&self, id: &EntryId) -> Result<(), StintError> {
256        self.storage.delete_entry(id)?;
257        Ok(())
258    }
259
260    /// Updates a time entry.
261    pub fn update_entry(&self, entry: &TimeEntry) -> Result<(), StintError> {
262        self.storage.update_entry(entry)?;
263        Ok(())
264    }
265
266    /// Resolves a project name to its ID for use in filters.
267    pub fn resolve_project_id(&self, name: &str) -> Result<ProjectId, StintError> {
268        let project = self
269            .storage
270            .get_project_by_name(name)?
271            .ok_or_else(|| StintError::InvalidInput(format!("project '{name}' not found")))?;
272        Ok(project.id)
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::models::project::{Project, ProjectSource};
280    use crate::storage::sqlite::SqliteStorage;
281    use std::path::PathBuf;
282
283    fn setup() -> StintService<SqliteStorage> {
284        let storage = SqliteStorage::open_in_memory().unwrap();
285        StintService::new(storage)
286    }
287
288    fn create_project(service: &StintService<SqliteStorage>, name: &str) {
289        let now = OffsetDateTime::now_utc();
290        let project = Project {
291            id: ProjectId::new(),
292            name: name.to_string(),
293            paths: vec![PathBuf::from(format!("/home/user/{name}"))],
294            tags: vec![],
295            hourly_rate_cents: None,
296            status: ProjectStatus::Active,
297            source: ProjectSource::Manual,
298            created_at: now,
299            updated_at: now,
300        };
301        service.storage().create_project(&project).unwrap();
302    }
303
304    #[test]
305    fn start_and_stop_timer() {
306        let service = setup();
307        create_project(&service, "my-app");
308
309        let (entry, project) = service.start_timer("my-app").unwrap();
310        assert!(entry.is_running());
311        assert_eq!(project.name, "my-app");
312
313        let (stopped, _) = service.stop_timer().unwrap();
314        assert!(!stopped.is_running());
315        assert!(stopped.duration_secs.unwrap() >= 0);
316    }
317
318    #[test]
319    fn start_while_running_errors() {
320        let service = setup();
321        create_project(&service, "app-1");
322        create_project(&service, "app-2");
323
324        service.start_timer("app-1").unwrap();
325        let result = service.start_timer("app-2");
326        assert!(matches!(result, Err(StintError::TimerAlreadyRunning(_))));
327    }
328
329    #[test]
330    fn stop_without_running_errors() {
331        let service = setup();
332        let result = service.stop_timer();
333        assert!(matches!(result, Err(StintError::NoRunningTimer)));
334    }
335
336    #[test]
337    fn start_archived_project_errors() {
338        let service = setup();
339        create_project(&service, "old-app");
340        service.archive_project("old-app").unwrap();
341
342        let result = service.start_timer("old-app");
343        assert!(matches!(result, Err(StintError::ProjectNotActive(_))));
344    }
345
346    #[test]
347    fn start_nonexistent_project_errors() {
348        let service = setup();
349        let result = service.start_timer("no-such-project");
350        assert!(matches!(result, Err(StintError::InvalidInput(_))));
351    }
352
353    #[test]
354    fn add_time_zero_duration_errors() {
355        let service = setup();
356        create_project(&service, "my-app");
357
358        let result = service.add_time("my-app", 0, None, None);
359        assert!(matches!(result, Err(StintError::InvalidInput(_))));
360    }
361
362    #[test]
363    fn add_time_negative_duration_errors() {
364        let service = setup();
365        create_project(&service, "my-app");
366
367        let result = service.add_time("my-app", -3600, None, None);
368        assert!(matches!(result, Err(StintError::InvalidInput(_))));
369    }
370
371    #[test]
372    fn add_time() {
373        let service = setup();
374        create_project(&service, "my-app");
375
376        let (entry, project) = service
377            .add_time("my-app", 3600, None, Some("Retroactive"))
378            .unwrap();
379
380        assert!(!entry.is_running());
381        assert_eq!(entry.duration_secs, Some(3600));
382        assert_eq!(entry.source, EntrySource::Added);
383        assert_eq!(entry.notes.as_deref(), Some("Retroactive"));
384        assert_eq!(project.name, "my-app");
385    }
386
387    #[test]
388    fn get_status_running() {
389        let service = setup();
390        create_project(&service, "my-app");
391        service.start_timer("my-app").unwrap();
392
393        let status = service.get_status().unwrap();
394        assert!(status.is_some());
395        let (entry, project) = status.unwrap();
396        assert!(entry.is_running());
397        assert_eq!(project.name, "my-app");
398    }
399
400    #[test]
401    fn get_status_idle() {
402        let service = setup();
403        let status = service.get_status().unwrap();
404        assert!(status.is_none());
405    }
406
407    #[test]
408    fn archive_stops_running_timer() {
409        let service = setup();
410        create_project(&service, "my-app");
411        service.start_timer("my-app").unwrap();
412
413        service.archive_project("my-app").unwrap();
414
415        // Timer should be stopped
416        let status = service.get_status().unwrap();
417        assert!(status.is_none());
418
419        // Project should be archived
420        let project = service
421            .storage()
422            .get_project_by_name("my-app")
423            .unwrap()
424            .unwrap();
425        assert_eq!(project.status, ProjectStatus::Archived);
426    }
427
428    #[test]
429    fn delete_project() {
430        let service = setup();
431        create_project(&service, "doomed");
432
433        let name = service.delete_project("doomed").unwrap();
434        assert_eq!(name, "doomed");
435        assert!(service
436            .storage()
437            .get_project_by_name("doomed")
438            .unwrap()
439            .is_none());
440    }
441
442    #[test]
443    fn get_entries_with_project() {
444        let service = setup();
445        create_project(&service, "my-app");
446        service.add_time("my-app", 3600, None, None).unwrap();
447        service.add_time("my-app", 1800, None, None).unwrap();
448
449        let entries = service.get_entries(&EntryFilter::default()).unwrap();
450        assert_eq!(entries.len(), 2);
451        assert_eq!(entries[0].1.name, "my-app");
452    }
453}