tempo_cli/services/
session_service.rs

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
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 = validate_project_id(project_id)
19            .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(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            // Filter by project if specified
64            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    /// Get session statistics for a date range
73    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            // Filter by project if specified
94            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    /// Pause the current session
106    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    /// Resume the current session
118    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    // Private implementation methods
130
131    async fn start_session_via_daemon(project_id: i64, context: SessionContext) -> Result<Session> {
132        // First get the project to find its path
133        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                // Get the newly started session
147                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            // Check for existing active session
160            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/// Statistics about sessions
245#[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    // use std::path::PathBuf;
260
261    #[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, // Active session
279                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); // 1 hour
289        assert_eq!(stats.average_duration_seconds, 1800); // 30 minutes
290        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            // Create test projects
308            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            // Test recent sessions without project filter
317            let all_recent = SessionService::list_recent_sessions(Some(10), None).await?;
318            assert!(!all_recent.is_empty() || all_recent.is_empty()); // Should succeed
319            
320            // Test recent sessions with project filter
321            let filtered_recent = SessionService::list_recent_sessions(Some(10), Some(project1_id)).await?;
322            assert!(!filtered_recent.is_empty() || filtered_recent.is_empty()); // Should succeed
323            
324            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        // Test with no date range (should use defaults)
335        let result = SessionService::get_session_stats(None, None, None).await;
336        assert!(result.is_ok());
337        
338        // Test with specific date range (past only)
339        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        // Test with project filter
347        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        // Test that service methods handle daemon not running gracefully
358        
359        // These will fall back to direct database operations
360        // when daemon is not running (which should be the case in tests)
361        
362        let active_result = SessionService::get_active_session().await;
363        assert!(active_result.is_ok());
364        
365        // Test pause/resume operations when daemon not running
366        let pause_result = SessionService::pause_session().await;
367        assert!(pause_result.is_err()); // Should fail when daemon not running
368        
369        let resume_result = SessionService::resume_session().await;
370        assert!(resume_result.is_err()); // Should fail when daemon not running
371    }
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, // Active
396                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, // Active
406                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); // No completed sessions
416        assert_eq!(stats.average_duration_seconds, 0);
417        assert!(stats.active_session_exists);
418    }
419}