1use 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
14pub struct StintService<S: Storage> {
16 storage: S,
17}
18
19impl<S: Storage> StintService<S> {
20 pub fn new(storage: S) -> Self {
22 Self { storage }
23 }
24
25 pub fn storage(&self) -> &S {
27 &self.storage
28 }
29
30 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 pub fn start_timer(&self, project_name: &str) -> Result<(TimeEntry, Project), StintError> {
48 let project = self.resolve_active_project(project_name)?;
49
50 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 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 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 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 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 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 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 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 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 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 pub fn delete_entry(&self, id: &EntryId) -> Result<(), StintError> {
256 self.storage.delete_entry(id)?;
257 Ok(())
258 }
259
260 pub fn update_entry(&self, entry: &TimeEntry) -> Result<(), StintError> {
262 self.storage.update_entry(entry)?;
263 Ok(())
264 }
265
266 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 let status = service.get_status().unwrap();
417 assert!(status.is_none());
418
419 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}