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