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#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct PickNextResponse {
183    pub suggestion_type: String,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub task: Option<Task>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub reason_code: Option<String>,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub message: Option<String>,
190}
191
192impl PickNextResponse {
193    /// Create a response for focused subtask suggestion
194    pub fn focused_subtask(task: Task) -> Self {
195        Self {
196            suggestion_type: "FOCUSED_SUB_TASK".to_string(),
197            task: Some(task),
198            reason_code: None,
199            message: None,
200        }
201    }
202
203    /// Create a response for top-level task suggestion
204    pub fn top_level_task(task: Task) -> Self {
205        Self {
206            suggestion_type: "TOP_LEVEL_TASK".to_string(),
207            task: Some(task),
208            reason_code: None,
209            message: None,
210        }
211    }
212
213    /// Create a response for no tasks in project
214    pub fn no_tasks_in_project() -> Self {
215        Self {
216            suggestion_type: "NONE".to_string(),
217            task: None,
218            reason_code: Some("NO_TASKS_IN_PROJECT".to_string()),
219            message: Some(
220                "No tasks found in this project. Your intent backlog is empty.".to_string(),
221            ),
222        }
223    }
224
225    /// Create a response for all tasks completed
226    pub fn all_tasks_completed() -> Self {
227        Self {
228            suggestion_type: "NONE".to_string(),
229            task: None,
230            reason_code: Some("ALL_TASKS_COMPLETED".to_string()),
231            message: Some("Project Complete! All intents have been realized.".to_string()),
232        }
233    }
234
235    /// Create a response for no available todos
236    pub fn no_available_todos() -> Self {
237        Self {
238            suggestion_type: "NONE".to_string(),
239            task: None,
240            reason_code: Some("NO_AVAILABLE_TODOS".to_string()),
241            message: Some("No immediate next task found based on the current context.".to_string()),
242        }
243    }
244
245    /// Format response as human-readable text
246    pub fn format_as_text(&self) -> String {
247        match self.suggestion_type.as_str() {
248            "FOCUSED_SUB_TASK" | "TOP_LEVEL_TASK" => {
249                if let Some(task) = &self.task {
250                    format!(
251                        "Based on your current focus, the recommended next task is:\n\n\
252                        [ID: {}] [Priority: {}] [Status: {}]\n\
253                        Name: {}\n\n\
254                        To start working on it, run:\n  ie task start {}",
255                        task.id,
256                        task.priority.unwrap_or(0),
257                        task.status,
258                        task.name,
259                        task.id
260                    )
261                } else {
262                    "[ERROR] Invalid response: task is missing".to_string()
263                }
264            },
265            "NONE" => {
266                let reason_code = self.reason_code.as_deref().unwrap_or("UNKNOWN");
267                let message = self.message.as_deref().unwrap_or("No tasks found");
268
269                match reason_code {
270                    "NO_TASKS_IN_PROJECT" => {
271                        format!(
272                            "[INFO] {}\n\n\
273                            To get started, capture your first high-level intent:\n  \
274                            ie task add --name \"Setup initial project structure\" --priority 1",
275                            message
276                        )
277                    },
278                    "ALL_TASKS_COMPLETED" => {
279                        format!(
280                            "[SUCCESS] {}\n\n\
281                            You can review the accomplishments of the last 30 days with:\n  \
282                            ie report --since 30d",
283                            message
284                        )
285                    },
286                    "NO_AVAILABLE_TODOS" => {
287                        format!(
288                            "[INFO] {}\n\n\
289                            Possible reasons:\n\
290                            - All available 'todo' tasks are part of larger epics that have not been started yet.\n\
291                            - You are not currently focused on a task that has 'todo' sub-tasks.\n\n\
292                            To see all available top-level tasks you can start, run:\n  \
293                            ie task find --parent NULL --status todo",
294                            message
295                        )
296                    },
297                    _ => format!("[INFO] {}", message),
298                }
299            },
300            _ => "[ERROR] Unknown suggestion type".to_string(),
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn create_test_task(id: i64, name: &str, priority: Option<i32>) -> Task {
310        Task {
311            id,
312            parent_id: None,
313            name: name.to_string(),
314            spec: None,
315            status: "todo".to_string(),
316            complexity: None,
317            priority,
318            first_todo_at: None,
319            first_doing_at: None,
320            first_done_at: None,
321        }
322    }
323
324    #[test]
325    fn test_pick_next_response_focused_subtask() {
326        let task = create_test_task(1, "Test task", Some(5));
327        let response = PickNextResponse::focused_subtask(task.clone());
328
329        assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
330        assert!(response.task.is_some());
331        assert_eq!(response.task.unwrap().id, 1);
332        assert!(response.reason_code.is_none());
333        assert!(response.message.is_none());
334    }
335
336    #[test]
337    fn test_pick_next_response_top_level_task() {
338        let task = create_test_task(2, "Top level task", Some(3));
339        let response = PickNextResponse::top_level_task(task.clone());
340
341        assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
342        assert!(response.task.is_some());
343        assert_eq!(response.task.unwrap().id, 2);
344        assert!(response.reason_code.is_none());
345        assert!(response.message.is_none());
346    }
347
348    #[test]
349    fn test_pick_next_response_no_tasks_in_project() {
350        let response = PickNextResponse::no_tasks_in_project();
351
352        assert_eq!(response.suggestion_type, "NONE");
353        assert!(response.task.is_none());
354        assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
355        assert!(response.message.is_some());
356        assert!(response.message.unwrap().contains("No tasks found"));
357    }
358
359    #[test]
360    fn test_pick_next_response_all_tasks_completed() {
361        let response = PickNextResponse::all_tasks_completed();
362
363        assert_eq!(response.suggestion_type, "NONE");
364        assert!(response.task.is_none());
365        assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
366        assert!(response.message.is_some());
367        assert!(response.message.unwrap().contains("Project Complete"));
368    }
369
370    #[test]
371    fn test_pick_next_response_no_available_todos() {
372        let response = PickNextResponse::no_available_todos();
373
374        assert_eq!(response.suggestion_type, "NONE");
375        assert!(response.task.is_none());
376        assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
377        assert!(response.message.is_some());
378    }
379
380    #[test]
381    fn test_format_as_text_focused_subtask() {
382        let task = create_test_task(1, "Test task", Some(5));
383        let response = PickNextResponse::focused_subtask(task);
384        let text = response.format_as_text();
385
386        assert!(text.contains("Based on your current focus"));
387        assert!(text.contains("[ID: 1]"));
388        assert!(text.contains("[Priority: 5]"));
389        assert!(text.contains("Test task"));
390        assert!(text.contains("ie task start 1"));
391    }
392
393    #[test]
394    fn test_format_as_text_top_level_task() {
395        let task = create_test_task(2, "Top level task", None);
396        let response = PickNextResponse::top_level_task(task);
397        let text = response.format_as_text();
398
399        assert!(text.contains("Based on your current focus"));
400        assert!(text.contains("[ID: 2]"));
401        assert!(text.contains("[Priority: 0]")); // Default priority
402        assert!(text.contains("Top level task"));
403        assert!(text.contains("ie task start 2"));
404    }
405
406    #[test]
407    fn test_format_as_text_no_tasks_in_project() {
408        let response = PickNextResponse::no_tasks_in_project();
409        let text = response.format_as_text();
410
411        assert!(text.contains("[INFO]"));
412        assert!(text.contains("No tasks found"));
413        assert!(text.contains("ie task add"));
414        assert!(text.contains("--priority 1"));
415    }
416
417    #[test]
418    fn test_format_as_text_all_tasks_completed() {
419        let response = PickNextResponse::all_tasks_completed();
420        let text = response.format_as_text();
421
422        assert!(text.contains("[SUCCESS]"));
423        assert!(text.contains("Project Complete"));
424        assert!(text.contains("ie report --since 30d"));
425    }
426
427    #[test]
428    fn test_format_as_text_no_available_todos() {
429        let response = PickNextResponse::no_available_todos();
430        let text = response.format_as_text();
431
432        assert!(text.contains("[INFO]"));
433        assert!(text.contains("No immediate next task"));
434        assert!(text.contains("Possible reasons"));
435        assert!(text.contains("ie task find"));
436    }
437
438    #[test]
439    fn test_error_response_serialization() {
440        use crate::error::IntentError;
441
442        let error = IntentError::TaskNotFound(123);
443        let response = error.to_error_response();
444
445        assert_eq!(response.code, "TASK_NOT_FOUND");
446        assert!(response.error.contains("123"));
447    }
448
449    #[test]
450    fn test_next_step_suggestion_serialization() {
451        let suggestion = NextStepSuggestion::ParentIsReady {
452            message: "Test message".to_string(),
453            parent_task_id: 1,
454            parent_task_name: "Parent".to_string(),
455        };
456
457        let json = serde_json::to_string(&suggestion).unwrap();
458        assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
459        assert!(json.contains("parent_task_id"));
460    }
461
462    #[test]
463    fn test_task_with_events_serialization() {
464        let task = create_test_task(1, "Test", Some(5));
465        let task_with_events = TaskWithEvents {
466            task,
467            events_summary: None,
468        };
469
470        let json = serde_json::to_string(&task_with_events).unwrap();
471        assert!(json.contains("\"id\":1"));
472        assert!(json.contains("\"name\":\"Test\""));
473        // events_summary should be skipped when None
474        assert!(!json.contains("events_summary"));
475    }
476
477    #[test]
478    fn test_report_summary_with_date_range() {
479        let from = Utc::now() - chrono::Duration::days(7);
480        let to = Utc::now();
481
482        let summary = ReportSummary {
483            total_tasks: 10,
484            tasks_by_status: StatusBreakdown {
485                todo: 5,
486                doing: 3,
487                done: 2,
488            },
489            total_events: 20,
490            date_range: Some(DateRange { from, to }),
491        };
492
493        let json = serde_json::to_string(&summary).unwrap();
494        assert!(json.contains("\"total_tasks\":10"));
495        assert!(json.contains("\"total_events\":20"));
496        assert!(json.contains("date_range"));
497    }
498}