tempo_cli/services/
session_service.rs

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
12/// Service layer for session-related business logic
13pub struct SessionService;
14
15impl SessionService {
16    /// Start a new session for a project
17    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        // Check if daemon is running, if not try to communicate directly with DB
22        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    /// Stop the current active session
30    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    /// Get the current active session
39    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    /// List recent sessions with optional filtering
48    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            // Filter by project if specified
65            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    /// Get session statistics for a date range
78    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            // Filter by project if specified
99            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    /// Pause the current session
115    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    /// Resume the current session
127    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    // Private implementation methods
139
140    async fn start_session_via_daemon(project_id: i64, context: SessionContext) -> Result<Session> {
141        // First get the project to find its path
142        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                // Get the newly started session
158                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            // Check for existing active session
171            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/// Statistics about sessions
258#[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    // use std::path::PathBuf;
273
274    #[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, // Active session
292                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); // 1 hour
302        assert_eq!(stats.average_duration_seconds, 1800); // 30 minutes
303        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            // Create test projects
321            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            // Test recent sessions without project filter
330            let all_recent = SessionService::list_recent_sessions(Some(10), None).await?;
331            assert!(!all_recent.is_empty() || all_recent.is_empty()); // Should succeed
332
333            // Test recent sessions with project filter
334            let filtered_recent =
335                SessionService::list_recent_sessions(Some(10), Some(project1_id)).await?;
336            assert!(!filtered_recent.is_empty() || filtered_recent.is_empty()); // Should succeed
337
338            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        // Test with no date range (should use defaults)
350        let result = SessionService::get_session_stats(None, None, None).await;
351        assert!(result.is_ok());
352
353        // Test with specific date range (past only)
354        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        // Test with project filter
359        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        // Test that service methods handle daemon not running gracefully
367
368        // These will fall back to direct database operations
369        // when daemon is not running (which should be the case in tests)
370
371        let active_result = SessionService::get_active_session().await;
372        assert!(active_result.is_ok());
373
374        // Test pause/resume operations when daemon not running
375        let pause_result = SessionService::pause_session().await;
376        assert!(pause_result.is_err()); // Should fail when daemon not running
377
378        let resume_result = SessionService::resume_session().await;
379        assert!(resume_result.is_err()); // Should fail when daemon not running
380    }
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, // Active
402                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, // Active
412                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); // No completed sessions
422        assert_eq!(stats.average_duration_seconds, 0);
423        assert!(stats.active_session_exists);
424    }
425}