intent_engine/db/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sqlx::FromRow;
4
5#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
6pub struct Dependency {
7    pub id: i64,
8    pub blocking_task_id: i64,
9    pub blocked_task_id: i64,
10    pub created_at: DateTime<Utc>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
14pub struct Task {
15    pub id: i64,
16    pub parent_id: Option<i64>,
17    pub name: String,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub spec: Option<String>,
20    pub status: String,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub complexity: Option<i32>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub priority: Option<i32>,
25    pub first_todo_at: Option<DateTime<Utc>>,
26    pub first_doing_at: Option<DateTime<Utc>>,
27    pub first_done_at: Option<DateTime<Utc>>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TaskWithEvents {
32    #[serde(flatten)]
33    pub task: Task,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub events_summary: Option<EventsSummary>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct EventsSummary {
40    pub total_count: i64,
41    pub recent_events: Vec<Event>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
45pub struct Event {
46    pub id: i64,
47    pub task_id: i64,
48    pub timestamp: DateTime<Utc>,
49    pub log_type: String,
50    pub discussion_data: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
54pub struct WorkspaceState {
55    pub key: String,
56    pub value: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Report {
61    pub summary: ReportSummary,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub tasks: Option<Vec<Task>>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub events: Option<Vec<Event>>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ReportSummary {
70    pub total_tasks: i64,
71    pub tasks_by_status: StatusBreakdown,
72    pub total_events: i64,
73    pub date_range: Option<DateRange>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct StatusBreakdown {
78    pub todo: i64,
79    pub doing: i64,
80    pub done: i64,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct DateRange {
85    pub from: DateTime<Utc>,
86    pub to: DateTime<Utc>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct DoneTaskResponse {
91    pub completed_task: Task,
92    pub workspace_status: WorkspaceStatus,
93    pub next_step_suggestion: NextStepSuggestion,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct WorkspaceStatus {
98    pub current_task_id: Option<i64>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(tag = "type")]
103pub enum NextStepSuggestion {
104    #[serde(rename = "PARENT_IS_READY")]
105    ParentIsReady {
106        message: String,
107        parent_task_id: i64,
108        parent_task_name: String,
109    },
110    #[serde(rename = "SIBLING_TASKS_REMAIN")]
111    SiblingTasksRemain {
112        message: String,
113        parent_task_id: i64,
114        parent_task_name: String,
115        remaining_siblings_count: i64,
116    },
117    #[serde(rename = "TOP_LEVEL_TASK_COMPLETED")]
118    TopLevelTaskCompleted {
119        message: String,
120        completed_task_id: i64,
121        completed_task_name: String,
122    },
123    #[serde(rename = "NO_PARENT_CONTEXT")]
124    NoParentContext {
125        message: String,
126        completed_task_id: i64,
127        completed_task_name: String,
128    },
129    #[serde(rename = "WORKSPACE_IS_CLEAR")]
130    WorkspaceIsClear {
131        message: String,
132        completed_task_id: i64,
133    },
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct TaskSearchResult {
138    #[serde(flatten)]
139    pub task: Task,
140    pub match_snippet: String,
141}
142
143/// Response for task switch command - includes previous and current task info
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct SwitchTaskResponse {
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub previous_task: Option<PreviousTaskInfo>,
148    pub current_task: CurrentTaskInfo,
149}
150
151/// Simplified task info for previous task (only id and status)
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct PreviousTaskInfo {
154    pub id: i64,
155    pub status: String,
156}
157
158/// Current task info for switch response
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct CurrentTaskInfo {
161    pub id: i64,
162    pub name: String,
163    pub status: String,
164}
165
166/// Response for spawn-subtask command - includes subtask and parent info
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SpawnSubtaskResponse {
169    pub subtask: SubtaskInfo,
170    pub parent_task: ParentTaskInfo,
171}
172
173/// Subtask info for spawn response
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SubtaskInfo {
176    pub id: i64,
177    pub name: String,
178    pub parent_id: i64,
179    pub status: String,
180}
181
182/// Parent task info for spawn response
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ParentTaskInfo {
185    pub id: i64,
186    pub name: String,
187}
188
189/// Dependency information for a task
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct TaskDependencies {
192    /// Tasks that must be completed before this task can start
193    pub blocking_tasks: Vec<Task>,
194    /// Tasks that are blocked by this task
195    pub blocked_by_tasks: Vec<Task>,
196}
197
198/// Response for task_context - provides the complete family tree of a task
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct TaskContext {
201    pub task: Task,
202    pub ancestors: Vec<Task>,
203    pub siblings: Vec<Task>,
204    pub children: Vec<Task>,
205    pub dependencies: TaskDependencies,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct PickNextResponse {
210    pub suggestion_type: String,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub task: Option<Task>,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub reason_code: Option<String>,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub message: Option<String>,
217}
218
219impl PickNextResponse {
220    /// Create a response for focused subtask suggestion
221    pub fn focused_subtask(task: Task) -> Self {
222        Self {
223            suggestion_type: "FOCUSED_SUB_TASK".to_string(),
224            task: Some(task),
225            reason_code: None,
226            message: None,
227        }
228    }
229
230    /// Create a response for top-level task suggestion
231    pub fn top_level_task(task: Task) -> Self {
232        Self {
233            suggestion_type: "TOP_LEVEL_TASK".to_string(),
234            task: Some(task),
235            reason_code: None,
236            message: None,
237        }
238    }
239
240    /// Create a response for no tasks in project
241    pub fn no_tasks_in_project() -> Self {
242        Self {
243            suggestion_type: "NONE".to_string(),
244            task: None,
245            reason_code: Some("NO_TASKS_IN_PROJECT".to_string()),
246            message: Some(
247                "No tasks found in this project. Your intent backlog is empty.".to_string(),
248            ),
249        }
250    }
251
252    /// Create a response for all tasks completed
253    pub fn all_tasks_completed() -> Self {
254        Self {
255            suggestion_type: "NONE".to_string(),
256            task: None,
257            reason_code: Some("ALL_TASKS_COMPLETED".to_string()),
258            message: Some("Project Complete! All intents have been realized.".to_string()),
259        }
260    }
261
262    /// Create a response for no available todos
263    pub fn no_available_todos() -> Self {
264        Self {
265            suggestion_type: "NONE".to_string(),
266            task: None,
267            reason_code: Some("NO_AVAILABLE_TODOS".to_string()),
268            message: Some("No immediate next task found based on the current context.".to_string()),
269        }
270    }
271
272    /// Format response as human-readable text
273    pub fn format_as_text(&self) -> String {
274        match self.suggestion_type.as_str() {
275            "FOCUSED_SUB_TASK" | "TOP_LEVEL_TASK" => {
276                if let Some(task) = &self.task {
277                    format!(
278                        "Based on your current focus, the recommended next task is:\n\n\
279                        [ID: {}] [Priority: {}] [Status: {}]\n\
280                        Name: {}\n\n\
281                        To start working on it, run:\n  ie task start {}",
282                        task.id,
283                        task.priority.unwrap_or(0),
284                        task.status,
285                        task.name,
286                        task.id
287                    )
288                } else {
289                    "[ERROR] Invalid response: task is missing".to_string()
290                }
291            },
292            "NONE" => {
293                let reason_code = self.reason_code.as_deref().unwrap_or("UNKNOWN");
294                let message = self.message.as_deref().unwrap_or("No tasks found");
295
296                match reason_code {
297                    "NO_TASKS_IN_PROJECT" => {
298                        format!(
299                            "[INFO] {}\n\n\
300                            To get started, capture your first high-level intent:\n  \
301                            ie task add --name \"Setup initial project structure\" --priority 1",
302                            message
303                        )
304                    },
305                    "ALL_TASKS_COMPLETED" => {
306                        format!(
307                            "[SUCCESS] {}\n\n\
308                            You can review the accomplishments of the last 30 days with:\n  \
309                            ie report --since 30d",
310                            message
311                        )
312                    },
313                    "NO_AVAILABLE_TODOS" => {
314                        format!(
315                            "[INFO] {}\n\n\
316                            Possible reasons:\n\
317                            - All available 'todo' tasks are part of larger epics that have not been started yet.\n\
318                            - You are not currently focused on a task that has 'todo' sub-tasks.\n\n\
319                            To see all available top-level tasks you can start, run:\n  \
320                            ie task find --parent NULL --status todo",
321                            message
322                        )
323                    },
324                    _ => format!("[INFO] {}", message),
325                }
326            },
327            _ => "[ERROR] Unknown suggestion type".to_string(),
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn create_test_task(id: i64, name: &str, priority: Option<i32>) -> Task {
337        Task {
338            id,
339            parent_id: None,
340            name: name.to_string(),
341            spec: None,
342            status: "todo".to_string(),
343            complexity: None,
344            priority,
345            first_todo_at: None,
346            first_doing_at: None,
347            first_done_at: None,
348        }
349    }
350
351    #[test]
352    fn test_pick_next_response_focused_subtask() {
353        let task = create_test_task(1, "Test task", Some(5));
354        let response = PickNextResponse::focused_subtask(task.clone());
355
356        assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
357        assert!(response.task.is_some());
358        assert_eq!(response.task.unwrap().id, 1);
359        assert!(response.reason_code.is_none());
360        assert!(response.message.is_none());
361    }
362
363    #[test]
364    fn test_pick_next_response_top_level_task() {
365        let task = create_test_task(2, "Top level task", Some(3));
366        let response = PickNextResponse::top_level_task(task.clone());
367
368        assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
369        assert!(response.task.is_some());
370        assert_eq!(response.task.unwrap().id, 2);
371        assert!(response.reason_code.is_none());
372        assert!(response.message.is_none());
373    }
374
375    #[test]
376    fn test_pick_next_response_no_tasks_in_project() {
377        let response = PickNextResponse::no_tasks_in_project();
378
379        assert_eq!(response.suggestion_type, "NONE");
380        assert!(response.task.is_none());
381        assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
382        assert!(response.message.is_some());
383        assert!(response.message.unwrap().contains("No tasks found"));
384    }
385
386    #[test]
387    fn test_pick_next_response_all_tasks_completed() {
388        let response = PickNextResponse::all_tasks_completed();
389
390        assert_eq!(response.suggestion_type, "NONE");
391        assert!(response.task.is_none());
392        assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
393        assert!(response.message.is_some());
394        assert!(response.message.unwrap().contains("Project Complete"));
395    }
396
397    #[test]
398    fn test_pick_next_response_no_available_todos() {
399        let response = PickNextResponse::no_available_todos();
400
401        assert_eq!(response.suggestion_type, "NONE");
402        assert!(response.task.is_none());
403        assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
404        assert!(response.message.is_some());
405    }
406
407    #[test]
408    fn test_format_as_text_focused_subtask() {
409        let task = create_test_task(1, "Test task", Some(5));
410        let response = PickNextResponse::focused_subtask(task);
411        let text = response.format_as_text();
412
413        assert!(text.contains("Based on your current focus"));
414        assert!(text.contains("[ID: 1]"));
415        assert!(text.contains("[Priority: 5]"));
416        assert!(text.contains("Test task"));
417        assert!(text.contains("ie task start 1"));
418    }
419
420    #[test]
421    fn test_format_as_text_top_level_task() {
422        let task = create_test_task(2, "Top level task", None);
423        let response = PickNextResponse::top_level_task(task);
424        let text = response.format_as_text();
425
426        assert!(text.contains("Based on your current focus"));
427        assert!(text.contains("[ID: 2]"));
428        assert!(text.contains("[Priority: 0]")); // Default priority
429        assert!(text.contains("Top level task"));
430        assert!(text.contains("ie task start 2"));
431    }
432
433    #[test]
434    fn test_format_as_text_no_tasks_in_project() {
435        let response = PickNextResponse::no_tasks_in_project();
436        let text = response.format_as_text();
437
438        assert!(text.contains("[INFO]"));
439        assert!(text.contains("No tasks found"));
440        assert!(text.contains("ie task add"));
441        assert!(text.contains("--priority 1"));
442    }
443
444    #[test]
445    fn test_format_as_text_all_tasks_completed() {
446        let response = PickNextResponse::all_tasks_completed();
447        let text = response.format_as_text();
448
449        assert!(text.contains("[SUCCESS]"));
450        assert!(text.contains("Project Complete"));
451        assert!(text.contains("ie report --since 30d"));
452    }
453
454    #[test]
455    fn test_format_as_text_no_available_todos() {
456        let response = PickNextResponse::no_available_todos();
457        let text = response.format_as_text();
458
459        assert!(text.contains("[INFO]"));
460        assert!(text.contains("No immediate next task"));
461        assert!(text.contains("Possible reasons"));
462        assert!(text.contains("ie task find"));
463    }
464
465    #[test]
466    fn test_error_response_serialization() {
467        use crate::error::IntentError;
468
469        let error = IntentError::TaskNotFound(123);
470        let response = error.to_error_response();
471
472        assert_eq!(response.code, "TASK_NOT_FOUND");
473        assert!(response.error.contains("123"));
474    }
475
476    #[test]
477    fn test_next_step_suggestion_serialization() {
478        let suggestion = NextStepSuggestion::ParentIsReady {
479            message: "Test message".to_string(),
480            parent_task_id: 1,
481            parent_task_name: "Parent".to_string(),
482        };
483
484        let json = serde_json::to_string(&suggestion).unwrap();
485        assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
486        assert!(json.contains("parent_task_id"));
487    }
488
489    #[test]
490    fn test_task_with_events_serialization() {
491        let task = create_test_task(1, "Test", Some(5));
492        let task_with_events = TaskWithEvents {
493            task,
494            events_summary: None,
495        };
496
497        let json = serde_json::to_string(&task_with_events).unwrap();
498        assert!(json.contains("\"id\":1"));
499        assert!(json.contains("\"name\":\"Test\""));
500        // events_summary should be skipped when None
501        assert!(!json.contains("events_summary"));
502    }
503
504    #[test]
505    fn test_report_summary_with_date_range() {
506        let from = Utc::now() - chrono::Duration::days(7);
507        let to = Utc::now();
508
509        let summary = ReportSummary {
510            total_tasks: 10,
511            tasks_by_status: StatusBreakdown {
512                todo: 5,
513                doing: 3,
514                done: 2,
515            },
516            total_events: 20,
517            date_range: Some(DateRange { from, to }),
518        };
519
520        let json = serde_json::to_string(&summary).unwrap();
521        assert!(json.contains("\"total_tasks\":10"));
522        assert!(json.contains("\"total_events\":20"));
523        assert!(json.contains("date_range"));
524    }
525}