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