intent_engine/
session_restore.rs

1use crate::error::Result;
2use crate::events::EventManager;
3use crate::tasks::TaskManager;
4use crate::workspace::WorkspaceManager;
5use serde::{Deserialize, Serialize};
6use sqlx::SqlitePool;
7
8/// Session restoration status
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum SessionStatus {
12    /// Successfully restored focus
13    Success,
14    /// No active focus (workspace exists but no current task)
15    NoFocus,
16    /// Error occurred
17    Error,
18}
19
20/// Error types for session restoration
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ErrorType {
24    WorkspaceNotFound,
25    DatabaseCorrupted,
26    PermissionDenied,
27}
28
29/// Simplified task info for session restoration
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TaskInfo {
32    pub id: i64,
33    pub name: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub status: Option<String>,
36}
37
38/// Detailed current task info
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CurrentTaskInfo {
41    pub id: i64,
42    pub name: String,
43    pub status: String,
44    /// Full spec
45    pub spec: Option<String>,
46    /// Truncated spec (first 100 chars)
47    pub spec_preview: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub created_at: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub first_doing_at: Option<String>,
52}
53
54/// Siblings information
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct SiblingsInfo {
57    pub total: usize,
58    pub done: usize,
59    pub doing: usize,
60    pub todo: usize,
61    pub done_list: Vec<TaskInfo>,
62}
63
64/// Children information
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ChildrenInfo {
67    pub total: usize,
68    pub todo: usize,
69    pub list: Vec<TaskInfo>,
70}
71
72/// Event information
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct EventInfo {
75    #[serde(rename = "type")]
76    pub event_type: String,
77    pub data: String,
78    pub timestamp: String,
79}
80
81/// Workspace statistics (for no-focus scenario)
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct WorkspaceStats {
84    pub total_tasks: usize,
85    pub todo: usize,
86    pub doing: usize,
87    pub done: usize,
88}
89
90/// Complete session restoration result
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct SessionRestoreResult {
93    pub status: SessionStatus,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub workspace_path: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub current_task: Option<CurrentTaskInfo>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub parent_task: Option<TaskInfo>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub siblings: Option<SiblingsInfo>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub children: Option<ChildrenInfo>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub recent_events: Option<Vec<EventInfo>>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub suggested_commands: Option<Vec<String>>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub stats: Option<WorkspaceStats>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub error_type: Option<ErrorType>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub message: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub recovery_suggestion: Option<String>,
116}
117
118/// Session restoration manager
119pub struct SessionRestoreManager<'a> {
120    pool: &'a SqlitePool,
121}
122
123impl<'a> SessionRestoreManager<'a> {
124    pub fn new(pool: &'a SqlitePool) -> Self {
125        Self { pool }
126    }
127
128    /// Restore session with full context
129    pub async fn restore(&self, include_events: usize) -> Result<SessionRestoreResult> {
130        let workspace_mgr = WorkspaceManager::new(self.pool);
131        let task_mgr = TaskManager::new(self.pool);
132        let event_mgr = EventManager::new(self.pool);
133
134        // Get current workspace path (for display purposes)
135        let workspace_path = std::env::current_dir()
136            .ok()
137            .and_then(|p| p.to_str().map(String::from));
138
139        // Get current task
140        let current_task_id = match workspace_mgr.get_current_task().await {
141            Ok(response) => {
142                if let Some(id) = response.current_task_id {
143                    id
144                } else {
145                    // No focus - return stats
146                    return self.restore_no_focus(workspace_path).await;
147                }
148            },
149            Err(_) => {
150                // Error getting current task
151                return Ok(Self::error_result(
152                    workspace_path,
153                    ErrorType::DatabaseCorrupted,
154                    "Unable to query workspace state",
155                    "Run 'ie workspace init' or check database integrity",
156                ));
157            },
158        };
159
160        // Get current task details
161        let task = match task_mgr.get_task(current_task_id).await {
162            Ok(t) => t,
163            Err(_) => {
164                return Ok(Self::error_result(
165                    workspace_path,
166                    ErrorType::DatabaseCorrupted,
167                    "Current task not found in database",
168                    "Run 'ie current --set <task_id>' to set a valid task",
169                ));
170            },
171        };
172
173        let spec_preview = task.spec.as_ref().map(|s| Self::truncate_spec(s, 100));
174
175        let current_task = CurrentTaskInfo {
176            id: task.id,
177            name: task.name.clone(),
178            status: task.status.clone(),
179            spec: task.spec.clone(),
180            spec_preview,
181            created_at: task.first_todo_at.map(|dt| dt.to_rfc3339()),
182            first_doing_at: task.first_doing_at.map(|dt| dt.to_rfc3339()),
183        };
184
185        // Get parent task
186        let parent_task = if let Some(parent_id) = task.parent_id {
187            task_mgr.get_task(parent_id).await.ok().map(|p| TaskInfo {
188                id: p.id,
189                name: p.name,
190                status: Some(p.status),
191            })
192        } else {
193            None
194        };
195
196        // Get siblings info
197        let siblings = if let Some(parent_id) = task.parent_id {
198            let all_siblings = task_mgr.find_tasks(None, Some(Some(parent_id))).await?;
199            Self::build_siblings_info(&all_siblings)
200        } else {
201            None
202        };
203
204        // Get children info
205        let children = {
206            let all_children = task_mgr
207                .find_tasks(None, Some(Some(current_task_id)))
208                .await?;
209            Self::build_children_info(&all_children)
210        };
211
212        // Get recent events
213        let events = event_mgr
214            .list_events(Some(current_task_id), None, None, None)
215            .await?;
216        let recent_events: Vec<EventInfo> = events
217            .into_iter()
218            .take(include_events)
219            .map(|e| EventInfo {
220                event_type: e.log_type,
221                data: e.discussion_data,
222                timestamp: e.timestamp.to_rfc3339(),
223            })
224            .collect();
225
226        // Suggest next commands based on context
227        let suggested_commands = Self::suggest_commands(&current_task, children.as_ref());
228
229        Ok(SessionRestoreResult {
230            status: SessionStatus::Success,
231            workspace_path,
232            current_task: Some(current_task),
233            parent_task,
234            siblings,
235            children,
236            recent_events: Some(recent_events),
237            suggested_commands: Some(suggested_commands),
238            stats: None,
239            error_type: None,
240            message: None,
241            recovery_suggestion: None,
242        })
243    }
244
245    /// Restore when no focus exists
246    async fn restore_no_focus(
247        &self,
248        workspace_path: Option<String>,
249    ) -> Result<SessionRestoreResult> {
250        let task_mgr = TaskManager::new(self.pool);
251
252        // Get all tasks for stats
253        let all_tasks = task_mgr.find_tasks(None, None).await?;
254
255        let stats = WorkspaceStats {
256            total_tasks: all_tasks.len(),
257            todo: all_tasks.iter().filter(|t| t.status == "todo").count(),
258            doing: all_tasks.iter().filter(|t| t.status == "doing").count(),
259            done: all_tasks.iter().filter(|t| t.status == "done").count(),
260        };
261
262        let suggested_commands = vec![
263            "ie pick-next".to_string(),
264            "ie task list --status todo".to_string(),
265        ];
266
267        Ok(SessionRestoreResult {
268            status: SessionStatus::NoFocus,
269            workspace_path,
270            current_task: None,
271            parent_task: None,
272            siblings: None,
273            children: None,
274            recent_events: None,
275            suggested_commands: Some(suggested_commands),
276            stats: Some(stats),
277            error_type: None,
278            message: None,
279            recovery_suggestion: None,
280        })
281    }
282
283    /// Build siblings information
284    fn build_siblings_info(siblings: &[crate::db::models::Task]) -> Option<SiblingsInfo> {
285        if siblings.is_empty() {
286            return None;
287        }
288
289        let done_list: Vec<TaskInfo> = siblings
290            .iter()
291            .filter(|s| s.status == "done")
292            .map(|s| TaskInfo {
293                id: s.id,
294                name: s.name.clone(),
295                status: Some(s.status.clone()),
296            })
297            .collect();
298
299        Some(SiblingsInfo {
300            total: siblings.len(),
301            done: siblings.iter().filter(|s| s.status == "done").count(),
302            doing: siblings.iter().filter(|s| s.status == "doing").count(),
303            todo: siblings.iter().filter(|s| s.status == "todo").count(),
304            done_list,
305        })
306    }
307
308    /// Build children information
309    fn build_children_info(children: &[crate::db::models::Task]) -> Option<ChildrenInfo> {
310        if children.is_empty() {
311            return None;
312        }
313
314        let list: Vec<TaskInfo> = children
315            .iter()
316            .map(|c| TaskInfo {
317                id: c.id,
318                name: c.name.clone(),
319                status: Some(c.status.clone()),
320            })
321            .collect();
322
323        Some(ChildrenInfo {
324            total: children.len(),
325            todo: children.iter().filter(|c| c.status == "todo").count(),
326            list,
327        })
328    }
329
330    /// Suggest next commands based on context
331    fn suggest_commands(
332        _current_task: &CurrentTaskInfo,
333        children: Option<&ChildrenInfo>,
334    ) -> Vec<String> {
335        let mut commands = vec![];
336
337        // If there are blockers, suggest viewing them
338        commands.push("ie event list --type blocker".to_string());
339
340        // Suggest completion or spawning subtasks
341        if let Some(children) = children {
342            if children.todo > 0 {
343                commands.push("ie task done".to_string());
344            }
345        } else {
346            commands.push("ie task done".to_string());
347        }
348
349        commands.push("ie task spawn-subtask".to_string());
350
351        commands
352    }
353
354    /// Truncate spec to specified length
355    fn truncate_spec(spec: &str, max_len: usize) -> String {
356        if spec.len() <= max_len {
357            spec.to_string()
358        } else {
359            format!("{}...", &spec[..max_len])
360        }
361    }
362
363    /// Create error result
364    fn error_result(
365        workspace_path: Option<String>,
366        error_type: ErrorType,
367        message: &str,
368        recovery: &str,
369    ) -> SessionRestoreResult {
370        let suggested_commands = match error_type {
371            ErrorType::WorkspaceNotFound => {
372                vec!["ie workspace init".to_string(), "ie help".to_string()]
373            },
374            ErrorType::DatabaseCorrupted => {
375                vec!["ie workspace init".to_string(), "ie doctor".to_string()]
376            },
377            ErrorType::PermissionDenied => {
378                vec!["chmod 644 .intent-engine/workspace.db".to_string()]
379            },
380        };
381
382        SessionRestoreResult {
383            status: SessionStatus::Error,
384            workspace_path,
385            current_task: None,
386            parent_task: None,
387            siblings: None,
388            children: None,
389            recent_events: None,
390            suggested_commands: Some(suggested_commands),
391            stats: None,
392            error_type: Some(error_type),
393            message: Some(message.to_string()),
394            recovery_suggestion: Some(recovery.to_string()),
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::events::EventManager;
403    use crate::tasks::TaskManager;
404    use crate::test_utils::test_helpers::TestContext;
405    use crate::workspace::WorkspaceManager;
406
407    #[test]
408    fn test_truncate_spec() {
409        let spec = "a".repeat(200);
410        let truncated = SessionRestoreManager::truncate_spec(&spec, 100);
411        assert_eq!(truncated.len(), 103); // 100 + "..."
412        assert!(truncated.ends_with("..."));
413    }
414
415    #[test]
416    fn test_truncate_spec_short() {
417        let spec = "Short spec";
418        let truncated = SessionRestoreManager::truncate_spec(spec, 100);
419        assert_eq!(truncated, spec);
420        assert!(!truncated.ends_with("..."));
421    }
422
423    #[tokio::test]
424    async fn test_restore_with_focus_minimal() {
425        let ctx = TestContext::new().await;
426        let pool = ctx.pool();
427
428        // Create a task and set it as current
429        let task_mgr = TaskManager::new(pool);
430        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
431
432        let workspace_mgr = WorkspaceManager::new(pool);
433        workspace_mgr.set_current_task(task.id).await.unwrap();
434
435        // Restore session
436        let restore_mgr = SessionRestoreManager::new(pool);
437        let result = restore_mgr.restore(3).await.unwrap();
438
439        // Verify
440        assert_eq!(result.status, SessionStatus::Success);
441        assert!(result.current_task.is_some());
442        let current_task = result.current_task.unwrap();
443        assert_eq!(current_task.id, task.id);
444        assert_eq!(current_task.name, "Test task");
445    }
446
447    #[tokio::test]
448    async fn test_restore_with_focus_rich_context() {
449        let ctx = TestContext::new().await;
450        let pool = ctx.pool();
451
452        let task_mgr = TaskManager::new(pool);
453        let event_mgr = EventManager::new(pool);
454        let workspace_mgr = WorkspaceManager::new(pool);
455
456        // Create parent task
457        let parent = task_mgr
458            .add_task("Parent task", Some("Parent spec"), None)
459            .await
460            .unwrap();
461
462        // Create 3 siblings (1 done, 1 doing, 1 todo)
463        let sibling1 = task_mgr
464            .add_task("Sibling 1", None, Some(parent.id))
465            .await
466            .unwrap();
467        task_mgr
468            .update_task(sibling1.id, None, None, None, Some("done"), None, None)
469            .await
470            .unwrap();
471
472        let current = task_mgr
473            .add_task("Current task", Some("Current spec"), Some(parent.id))
474            .await
475            .unwrap();
476        task_mgr
477            .update_task(current.id, None, None, None, Some("doing"), None, None)
478            .await
479            .unwrap();
480        workspace_mgr.set_current_task(current.id).await.unwrap();
481
482        let _sibling3 = task_mgr
483            .add_task("Sibling 3", None, Some(parent.id))
484            .await
485            .unwrap();
486
487        // Add children to current task
488        let _child1 = task_mgr
489            .add_task("Child 1", None, Some(current.id))
490            .await
491            .unwrap();
492        let _child2 = task_mgr
493            .add_task("Child 2", None, Some(current.id))
494            .await
495            .unwrap();
496
497        // Add events
498        event_mgr
499            .add_event(current.id, "decision", "Decision 1")
500            .await
501            .unwrap();
502        event_mgr
503            .add_event(current.id, "blocker", "Blocker 1")
504            .await
505            .unwrap();
506        event_mgr
507            .add_event(current.id, "note", "Note 1")
508            .await
509            .unwrap();
510
511        // Restore session
512        let restore_mgr = SessionRestoreManager::new(pool);
513        let result = restore_mgr.restore(3).await.unwrap();
514
515        // Verify complete context
516        assert_eq!(result.status, SessionStatus::Success);
517
518        let task = result.current_task.unwrap();
519        assert_eq!(task.id, current.id);
520        assert_eq!(task.spec, Some("Current spec".to_string()));
521
522        // Verify parent
523        assert!(result.parent_task.is_some());
524        let parent_info = result.parent_task.unwrap();
525        assert_eq!(parent_info.id, parent.id);
526
527        // Verify siblings
528        assert!(result.siblings.is_some());
529        let siblings = result.siblings.unwrap();
530        assert_eq!(siblings.total, 3);
531        assert_eq!(siblings.done, 1);
532        assert_eq!(siblings.doing, 1);
533        assert_eq!(siblings.todo, 1);
534        assert_eq!(siblings.done_list.len(), 1);
535
536        // Verify children
537        assert!(result.children.is_some());
538        let children = result.children.unwrap();
539        assert_eq!(children.total, 2);
540        assert_eq!(children.todo, 2);
541
542        // Verify events
543        assert!(result.recent_events.is_some());
544        let events = result.recent_events.unwrap();
545        assert_eq!(events.len(), 3);
546
547        // Check event types
548        let event_types: Vec<&str> = events.iter().map(|e| e.event_type.as_str()).collect();
549        assert!(event_types.contains(&"decision"));
550        assert!(event_types.contains(&"blocker"));
551        assert!(event_types.contains(&"note"));
552    }
553
554    #[tokio::test]
555    async fn test_restore_with_spec_preview() {
556        let ctx = TestContext::new().await;
557        let pool = ctx.pool();
558
559        let task_mgr = TaskManager::new(pool);
560        let workspace_mgr = WorkspaceManager::new(pool);
561
562        // Create task with long spec
563        let long_spec = "a".repeat(200);
564        let task = task_mgr
565            .add_task("Test task", Some(&long_spec), None)
566            .await
567            .unwrap();
568        workspace_mgr.set_current_task(task.id).await.unwrap();
569
570        // Restore
571        let restore_mgr = SessionRestoreManager::new(pool);
572        let result = restore_mgr.restore(3).await.unwrap();
573
574        // Verify spec preview is truncated
575        let current_task = result.current_task.unwrap();
576        assert_eq!(current_task.spec, Some(long_spec));
577        assert!(current_task.spec_preview.is_some());
578        let preview = current_task.spec_preview.unwrap();
579        assert_eq!(preview.len(), 103); // 100 + "..."
580        assert!(preview.ends_with("..."));
581    }
582
583    #[tokio::test]
584    async fn test_restore_no_focus() {
585        let ctx = TestContext::new().await;
586        let pool = ctx.pool();
587
588        let task_mgr = TaskManager::new(pool);
589
590        // Create some tasks but no current task
591        task_mgr.add_task("Task 1", None, None).await.unwrap();
592        task_mgr.add_task("Task 2", None, None).await.unwrap();
593
594        // Restore
595        let restore_mgr = SessionRestoreManager::new(pool);
596        let result = restore_mgr.restore(3).await.unwrap();
597
598        // Verify no focus status
599        assert_eq!(result.status, SessionStatus::NoFocus);
600        assert!(result.current_task.is_none());
601        assert!(result.stats.is_some());
602
603        let stats = result.stats.unwrap();
604        assert_eq!(stats.total_tasks, 2);
605        assert_eq!(stats.todo, 2);
606    }
607
608    #[tokio::test]
609    async fn test_restore_recent_events_limit() {
610        let ctx = TestContext::new().await;
611        let pool = ctx.pool();
612
613        let task_mgr = TaskManager::new(pool);
614        let event_mgr = EventManager::new(pool);
615        let workspace_mgr = WorkspaceManager::new(pool);
616
617        // Create task
618        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
619        workspace_mgr.set_current_task(task.id).await.unwrap();
620
621        // Add 10 events
622        for i in 0..10 {
623            event_mgr
624                .add_event(task.id, "note", &format!("Event {}", i))
625                .await
626                .unwrap();
627        }
628
629        // Restore with default limit (3)
630        let restore_mgr = SessionRestoreManager::new(pool);
631        let result = restore_mgr.restore(3).await.unwrap();
632
633        // Should only return 3 events (most recent)
634        let events = result.recent_events.unwrap();
635        assert_eq!(events.len(), 3);
636    }
637
638    #[tokio::test]
639    async fn test_restore_custom_events_limit() {
640        let ctx = TestContext::new().await;
641        let pool = ctx.pool();
642
643        let task_mgr = TaskManager::new(pool);
644        let event_mgr = EventManager::new(pool);
645        let workspace_mgr = WorkspaceManager::new(pool);
646
647        // Create task
648        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
649        workspace_mgr.set_current_task(task.id).await.unwrap();
650
651        // Add 10 events
652        for i in 0..10 {
653            event_mgr
654                .add_event(task.id, "note", &format!("Event {}", i))
655                .await
656                .unwrap();
657        }
658
659        // Restore with custom limit (5)
660        let restore_mgr = SessionRestoreManager::new(pool);
661        let result = restore_mgr.restore(5).await.unwrap();
662
663        // Should return 5 events
664        let events = result.recent_events.unwrap();
665        assert_eq!(events.len(), 5);
666    }
667
668    #[test]
669    fn test_suggest_commands_with_children() {
670        let current_task = CurrentTaskInfo {
671            id: 1,
672            name: "Test".to_string(),
673            status: "doing".to_string(),
674            spec: None,
675            spec_preview: None,
676            created_at: None,
677            first_doing_at: None,
678        };
679
680        let children = Some(ChildrenInfo {
681            total: 2,
682            todo: 1,
683            list: vec![],
684        });
685
686        let commands = SessionRestoreManager::suggest_commands(&current_task, children.as_ref());
687
688        assert!(!commands.is_empty());
689        assert!(commands.iter().any(|c| c.contains("blocker")));
690    }
691
692    #[test]
693    fn test_error_result_workspace_not_found() {
694        let result = SessionRestoreManager::error_result(
695            Some("/test/path".to_string()),
696            ErrorType::WorkspaceNotFound,
697            "Test message",
698            "Test recovery",
699        );
700
701        assert_eq!(result.status, SessionStatus::Error);
702        assert_eq!(result.error_type, Some(ErrorType::WorkspaceNotFound));
703        assert_eq!(result.message, Some("Test message".to_string()));
704        assert_eq!(
705            result.recovery_suggestion,
706            Some("Test recovery".to_string())
707        );
708        assert!(result.suggested_commands.is_some());
709
710        let commands = result.suggested_commands.unwrap();
711        assert!(commands.iter().any(|c| c.contains("init")));
712    }
713
714    #[test]
715    fn test_build_siblings_info_empty() {
716        let siblings: Vec<crate::db::models::Task> = vec![];
717        let result = SessionRestoreManager::build_siblings_info(&siblings);
718        assert!(result.is_none());
719    }
720
721    #[test]
722    fn test_build_children_info_empty() {
723        let children: Vec<crate::db::models::Task> = vec![];
724        let result = SessionRestoreManager::build_children_info(&children);
725        assert!(result.is_none());
726    }
727}