tempo_cli/services/
session_service.rs

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
15/// Service layer for session-related business logic
16pub struct SessionService;
17
18impl SessionService {
19    /// Start a new session for a project
20    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        // Check if daemon is running, if not try to communicate directly with DB
25        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    /// Stop the current active session
33    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    /// Get the current active session
42    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    /// List recent sessions with optional filtering
51    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            // Filter by project if specified
67            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    /// Get session statistics for a date range
76    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            // Filter by project if specified
97            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    /// Pause the current session
109    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    /// Resume the current session
121    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    // Private implementation methods
133
134    async fn start_session_via_daemon(project_id: i64, context: SessionContext) -> Result<Session> {
135        // First get the project to find its path
136        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                // Get the newly started session
150                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            // Check for existing active session
163            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/// Statistics about sessions
248#[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, // Active session
282                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); // 1 hour
292        assert_eq!(stats.average_duration_seconds, 1800); // 30 minutes
293        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            // Create test projects
311            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            // Test recent sessions without project filter
320            let all_recent = SessionService::list_recent_sessions(Some(10), None).await?;
321            assert!(!all_recent.is_empty() || all_recent.is_empty()); // Should succeed
322            
323            // Test recent sessions with project filter
324            let filtered_recent = SessionService::list_recent_sessions(Some(10), Some(project1_id)).await?;
325            assert!(!filtered_recent.is_empty() || filtered_recent.is_empty()); // Should succeed
326            
327            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        // Test with no date range (should use defaults)
338        let result = SessionService::get_session_stats(None, None, None).await;
339        assert!(result.is_ok());
340        
341        // Test with specific date range (past only)
342        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        // Test with project filter
350        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        // Test that service methods handle daemon not running gracefully
361        
362        // These will fall back to direct database operations
363        // when daemon is not running (which should be the case in tests)
364        
365        let active_result = SessionService::get_active_session().await;
366        assert!(active_result.is_ok());
367        
368        // Test pause/resume operations when daemon not running
369        let pause_result = SessionService::pause_session().await;
370        assert!(pause_result.is_err()); // Should fail when daemon not running
371        
372        let resume_result = SessionService::resume_session().await;
373        assert!(resume_result.is_err()); // Should fail when daemon not running
374    }
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, // Active
399                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, // Active
409                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); // No completed sessions
419        assert_eq!(stats.average_duration_seconds, 0);
420        assert!(stats.active_session_exists);
421    }
422}