1use anyhow::{Result, Context};
2use chrono::{DateTime, Duration, Utc};
3
4use crate::db::{Database, get_database_path};
5use crate::db::queries::SessionQueries;
6use crate::models::{Session, SessionContext};
7use crate::utils::ipc::{IpcClient, IpcMessage, IpcResponse, get_socket_path, is_daemon_running};
8use crate::utils::validation::{
9 validate_project_id, validate_date_range, validate_query_limit
10};
11
12pub struct SessionService;
14
15impl SessionService {
16 pub async fn start_session(project_id: i64, context: SessionContext) -> Result<Session> {
18 let validated_id = validate_project_id(project_id)
19 .context("Invalid project ID for session start")?;
20
21 if is_daemon_running() {
23 Self::start_session_via_daemon(validated_id, context).await
24 } else {
25 Self::start_session_direct(validated_id, context).await
26 }
27 }
28
29 pub async fn stop_session() -> Result<()> {
31 if is_daemon_running() {
32 Self::stop_session_via_daemon().await
33 } else {
34 Self::stop_session_direct().await
35 }
36 }
37
38 pub async fn get_active_session() -> Result<Option<Session>> {
40 if is_daemon_running() {
41 Self::get_active_session_via_daemon().await
42 } else {
43 Self::get_active_session_direct().await
44 }
45 }
46
47 pub async fn list_recent_sessions(limit: Option<usize>, project_id: Option<i64>) -> Result<Vec<Session>> {
49 let validated_limit = validate_query_limit(limit)
50 .context("Invalid limit parameter")?;
51
52 let validated_project_id = if let Some(pid) = project_id {
53 Some(validate_project_id(pid)
54 .context("Invalid project ID for filtering")?)
55 } else {
56 None
57 };
58
59 tokio::task::spawn_blocking(move || -> Result<Vec<Session>> {
60 let db = Self::get_database_sync()?;
61 let sessions = SessionQueries::list_recent(&db.connection, validated_limit)?;
62
63 if let Some(pid) = validated_project_id {
65 Ok(sessions.into_iter().filter(|s| s.project_id == pid).collect())
66 } else {
67 Ok(sessions)
68 }
69 }).await?
70 }
71
72 pub async fn get_session_stats(
74 from_date: Option<DateTime<Utc>>,
75 to_date: Option<DateTime<Utc>>,
76 project_id: Option<i64>
77 ) -> Result<SessionStats> {
78 let (validated_from, validated_to) = validate_date_range(from_date, to_date)
79 .context("Invalid date range for session statistics")?;
80
81 let validated_project_id = if let Some(pid) = project_id {
82 Some(validate_project_id(pid)
83 .context("Invalid project ID for filtering")?)
84 } else {
85 None
86 };
87
88 tokio::task::spawn_blocking(move || -> Result<SessionStats> {
89 let db = Self::get_database_sync()?;
90
91 let sessions = SessionQueries::list_by_date_range(&db.connection, validated_from, validated_to)?;
92
93 let filtered_sessions: Vec<Session> = if let Some(pid) = validated_project_id {
95 sessions.into_iter().filter(|s| s.project_id == pid).collect()
96 } else {
97 sessions
98 };
99
100 let stats = Self::calculate_stats(&filtered_sessions);
101 Ok(stats)
102 }).await?
103 }
104
105 pub async fn pause_session() -> Result<()> {
107 if is_daemon_running() {
108 let socket_path = get_socket_path()?;
109 let mut client = IpcClient::connect(&socket_path).await?;
110 let _response = client.send_message(&IpcMessage::PauseSession).await?;
111 Ok(())
112 } else {
113 Err(anyhow::anyhow!("Cannot pause session: The tempo daemon is not running. Start it with 'tempo start'."))
114 }
115 }
116
117 pub async fn resume_session() -> Result<()> {
119 if is_daemon_running() {
120 let socket_path = get_socket_path()?;
121 let mut client = IpcClient::connect(&socket_path).await?;
122 let _response = client.send_message(&IpcMessage::ResumeSession).await?;
123 Ok(())
124 } else {
125 Err(anyhow::anyhow!("Cannot resume session: The tempo daemon is not running. Start it with 'tempo start'."))
126 }
127 }
128
129 async fn start_session_via_daemon(project_id: i64, context: SessionContext) -> Result<Session> {
132 let project = Self::get_project_by_id_sync(project_id)?
134 .ok_or_else(|| anyhow::anyhow!("Project with ID {} not found. Ensure the project exists before starting a session.", project_id))?;
135
136 let socket_path = get_socket_path()?;
137 let mut client = IpcClient::connect(&socket_path).await?;
138
139 let response = client.send_message(&IpcMessage::StartSession {
140 project_path: Some(project.path),
141 context: context.to_string()
142 }).await?;
143
144 match response {
145 IpcResponse::Success => {
146 Self::get_active_session_via_daemon().await?
148 .ok_or_else(|| anyhow::anyhow!("Session started successfully but could not retrieve session details. Try 'tempo status' to check the current session."))
149 },
150 IpcResponse::Error(e) => Err(anyhow::anyhow!("Failed to start session: {}", e)),
151 _ => Err(anyhow::anyhow!("Unexpected response from daemon")),
152 }
153 }
154
155 async fn start_session_direct(project_id: i64, context: SessionContext) -> Result<Session> {
156 tokio::task::spawn_blocking(move || -> Result<Session> {
157 let db = Self::get_database_sync()?;
158
159 if let Some(_active) = SessionQueries::find_active_session(&db.connection)? {
161 return Err(anyhow::anyhow!("Another session is already active. Stop the current session with 'tempo stop' before starting a new one."));
162 }
163
164 let session = Session::new(project_id, context);
165 let session_id = SessionQueries::create(&db.connection, &session)?;
166
167 let mut saved_session = session;
168 saved_session.id = Some(session_id);
169 Ok(saved_session)
170 }).await?
171 }
172
173 async fn stop_session_via_daemon() -> Result<()> {
174 let socket_path = get_socket_path()?;
175 let mut client = IpcClient::connect(&socket_path).await?;
176 let _response = client.send_message(&IpcMessage::StopSession).await?;
177 Ok(())
178 }
179
180 async fn stop_session_direct() -> Result<()> {
181 tokio::task::spawn_blocking(move || -> Result<()> {
182 let db = Self::get_database_sync()?;
183
184 if let Some(session) = SessionQueries::find_active_session(&db.connection)? {
185 let session_id = session.id
186 .ok_or_else(|| anyhow::anyhow!("Found active session but it has no ID. This indicates a database corruption issue."))?;
187 SessionQueries::end_session(&db.connection, session_id)?;
188 }
189
190 Ok(())
191 }).await?
192 }
193
194 async fn get_active_session_via_daemon() -> Result<Option<Session>> {
195 let socket_path = get_socket_path()?;
196 let mut client = IpcClient::connect(&socket_path).await?;
197
198 let response = client.send_message(&IpcMessage::GetActiveSession).await?;
199 match response {
200 IpcResponse::ActiveSession(session) => Ok(session),
201 IpcResponse::Error(_) => Ok(None),
202 _ => Ok(None),
203 }
204 }
205
206 async fn get_active_session_direct() -> Result<Option<Session>> {
207 tokio::task::spawn_blocking(move || -> Result<Option<Session>> {
208 let db = Self::get_database_sync()?;
209 SessionQueries::find_active_session(&db.connection)
210 }).await?
211 }
212
213 fn calculate_stats(sessions: &[Session]) -> SessionStats {
214 let total_sessions = sessions.len();
215 let total_duration: i64 = sessions.iter()
216 .filter_map(|s| s.end_time.map(|end| (end - s.start_time).num_seconds()))
217 .sum();
218
219 let avg_duration = if total_sessions > 0 {
220 total_duration / total_sessions as i64
221 } else {
222 0
223 };
224
225 SessionStats {
226 total_sessions,
227 total_duration_seconds: total_duration,
228 average_duration_seconds: avg_duration,
229 active_session_exists: sessions.iter().any(|s| s.end_time.is_none()),
230 }
231 }
232
233 fn get_database_sync() -> Result<Database> {
234 let db_path = get_database_path()?;
235 Database::new(&db_path)
236 }
237
238 fn get_project_by_id_sync(project_id: i64) -> Result<Option<crate::models::Project>> {
239 let db = Self::get_database_sync()?;
240 crate::db::queries::ProjectQueries::find_by_id(&db.connection, project_id)
241 }
242}
243
244#[derive(Debug, Clone)]
246pub struct SessionStats {
247 pub total_sessions: usize,
248 pub total_duration_seconds: i64,
249 pub average_duration_seconds: i64,
250 pub active_session_exists: bool,
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::test_utils::with_test_db_async;
257 use crate::db::queries::ProjectQueries;
258 use crate::models::Project;
259 #[tokio::test]
262 async fn test_session_stats_calculation() {
263 let sessions = vec![
264 Session {
265 id: Some(1),
266 project_id: 1,
267 start_time: Utc::now() - Duration::hours(2),
268 end_time: Some(Utc::now() - Duration::hours(1)),
269 context: SessionContext::Terminal,
270 notes: None,
271 paused_duration: Duration::zero(),
272 created_at: Utc::now() - Duration::hours(2),
273 },
274 Session {
275 id: Some(2),
276 project_id: 1,
277 start_time: Utc::now() - Duration::minutes(30),
278 end_time: None, context: SessionContext::IDE,
280 notes: None,
281 paused_duration: Duration::zero(),
282 created_at: Utc::now() - Duration::minutes(30),
283 },
284 ];
285
286 let stats = SessionService::calculate_stats(&sessions);
287 assert_eq!(stats.total_sessions, 2);
288 assert_eq!(stats.total_duration_seconds, 3600); assert_eq!(stats.average_duration_seconds, 1800); assert!(stats.active_session_exists);
291 }
292
293 #[tokio::test]
294 async fn test_empty_session_stats() {
295 let empty_sessions: Vec<Session> = vec![];
296 let stats = SessionService::calculate_stats(&empty_sessions);
297
298 assert_eq!(stats.total_sessions, 0);
299 assert_eq!(stats.total_duration_seconds, 0);
300 assert_eq!(stats.average_duration_seconds, 0);
301 assert!(!stats.active_session_exists);
302 }
303
304 #[tokio::test]
305 async fn test_session_filtering_by_project() {
306 with_test_db_async(|ctx| async move {
307 let project1_path = ctx.create_temp_project_dir()?;
309 let project1 = Project::new("Project 1".to_string(), project1_path);
310 let project1_id = ProjectQueries::create(&ctx.connection(), &project1)?;
311
312 let project2_path = ctx.create_temp_git_repo()?;
313 let project2 = Project::new("Project 2".to_string(), project2_path);
314 let _project2_id = ProjectQueries::create(&ctx.connection(), &project2)?;
315
316 let all_recent = SessionService::list_recent_sessions(Some(10), None).await?;
318 assert!(!all_recent.is_empty() || all_recent.is_empty()); let filtered_recent = SessionService::list_recent_sessions(Some(10), Some(project1_id)).await?;
322 assert!(!filtered_recent.is_empty() || filtered_recent.is_empty()); Ok(())
325 }).await;
326 }
327
328 #[tokio::test]
329 async fn test_session_date_range_filtering() {
330 let now = Utc::now();
331 let yesterday = now - Duration::days(1);
332 let last_week = now - Duration::days(7);
333
334 let result = SessionService::get_session_stats(None, None, None).await;
336 assert!(result.is_ok());
337
338 let result_with_range = SessionService::get_session_stats(
340 Some(last_week),
341 Some(yesterday),
342 None
343 ).await;
344 assert!(result_with_range.is_ok());
345
346 let result_with_project = SessionService::get_session_stats(
348 Some(last_week),
349 Some(yesterday),
350 Some(1)
351 ).await;
352 assert!(result_with_project.is_ok());
353 }
354
355 #[tokio::test]
356 async fn test_daemon_fallback_logic() {
357 let active_result = SessionService::get_active_session().await;
363 assert!(active_result.is_ok());
364
365 let pause_result = SessionService::pause_session().await;
367 assert!(pause_result.is_err()); let resume_result = SessionService::resume_session().await;
370 assert!(resume_result.is_err()); }
372
373 #[tokio::test]
374 async fn test_session_context_variations() {
375 let contexts = vec![
376 SessionContext::Terminal,
377 SessionContext::IDE,
378 ];
379
380 for context in contexts {
381 let session = Session::new(1, context);
382 assert_eq!(session.context, context);
383 assert!(session.end_time.is_none());
384 assert_eq!(session.paused_duration, Duration::zero());
385 }
386 }
387
388 #[tokio::test]
389 async fn test_stats_with_only_active_sessions() {
390 let active_only_sessions = vec![
391 Session {
392 id: Some(1),
393 project_id: 1,
394 start_time: Utc::now() - Duration::hours(1),
395 end_time: None, context: SessionContext::Terminal,
397 notes: None,
398 paused_duration: Duration::zero(),
399 created_at: Utc::now() - Duration::hours(1),
400 },
401 Session {
402 id: Some(2),
403 project_id: 1,
404 start_time: Utc::now() - Duration::minutes(30),
405 end_time: None, context: SessionContext::IDE,
407 notes: None,
408 paused_duration: Duration::zero(),
409 created_at: Utc::now() - Duration::minutes(30),
410 },
411 ];
412
413 let stats = SessionService::calculate_stats(&active_only_sessions);
414 assert_eq!(stats.total_sessions, 2);
415 assert_eq!(stats.total_duration_seconds, 0); assert_eq!(stats.average_duration_seconds, 0);
417 assert!(stats.active_session_exists);
418 }
419}