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