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    /// Present progressive form for UI display when task is in_progress
29    /// Example: "Implementing authentication" vs "Implement authentication"
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub active_form: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct TaskWithEvents {
36    #[serde(flatten)]
37    pub task: Task,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub events_summary: Option<EventsSummary>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct EventsSummary {
44    pub total_count: i64,
45    pub recent_events: Vec<Event>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
49pub struct Event {
50    pub id: i64,
51    pub task_id: i64,
52    pub timestamp: DateTime<Utc>,
53    pub log_type: String,
54    pub discussion_data: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
58pub struct WorkspaceState {
59    pub key: String,
60    pub value: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct Report {
65    pub summary: ReportSummary,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub tasks: Option<Vec<Task>>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub events: Option<Vec<Event>>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ReportSummary {
74    pub total_tasks: i64,
75    pub tasks_by_status: StatusBreakdown,
76    pub total_events: i64,
77    pub date_range: Option<DateRange>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct StatusBreakdown {
82    pub todo: i64,
83    pub doing: i64,
84    pub done: i64,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct DateRange {
89    pub from: DateTime<Utc>,
90    pub to: DateTime<Utc>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct DoneTaskResponse {
95    pub completed_task: Task,
96    pub workspace_status: WorkspaceStatus,
97    pub next_step_suggestion: NextStepSuggestion,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct WorkspaceStatus {
102    pub current_task_id: Option<i64>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(tag = "type")]
107pub enum NextStepSuggestion {
108    #[serde(rename = "PARENT_IS_READY")]
109    ParentIsReady {
110        message: String,
111        parent_task_id: i64,
112        parent_task_name: String,
113    },
114    #[serde(rename = "SIBLING_TASKS_REMAIN")]
115    SiblingTasksRemain {
116        message: String,
117        parent_task_id: i64,
118        parent_task_name: String,
119        remaining_siblings_count: i64,
120    },
121    #[serde(rename = "TOP_LEVEL_TASK_COMPLETED")]
122    TopLevelTaskCompleted {
123        message: String,
124        completed_task_id: i64,
125        completed_task_name: String,
126    },
127    #[serde(rename = "NO_PARENT_CONTEXT")]
128    NoParentContext {
129        message: String,
130        completed_task_id: i64,
131        completed_task_name: String,
132    },
133    #[serde(rename = "WORKSPACE_IS_CLEAR")]
134    WorkspaceIsClear {
135        message: String,
136        completed_task_id: i64,
137    },
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct TaskSearchResult {
142    #[serde(flatten)]
143    pub task: Task,
144    pub match_snippet: String,
145}
146
147/// Unified search result that can represent either a task or event match
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(tag = "result_type")]
150pub enum UnifiedSearchResult {
151    #[serde(rename = "task")]
152    Task {
153        #[serde(flatten)]
154        task: Task,
155        match_snippet: String,
156        match_field: String, // "name" or "spec"
157    },
158    #[serde(rename = "event")]
159    Event {
160        event: Event,
161        task_chain: Vec<Task>, // Ancestry: [immediate task, parent, grandparent, ...]
162        match_snippet: String,
163    },
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            active_form: None,
349        }
350    }
351
352    #[test]
353    fn test_pick_next_response_focused_subtask() {
354        let task = create_test_task(1, "Test task", Some(5));
355        let response = PickNextResponse::focused_subtask(task.clone());
356
357        assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
358        assert!(response.task.is_some());
359        assert_eq!(response.task.unwrap().id, 1);
360        assert!(response.reason_code.is_none());
361        assert!(response.message.is_none());
362    }
363
364    #[test]
365    fn test_pick_next_response_top_level_task() {
366        let task = create_test_task(2, "Top level task", Some(3));
367        let response = PickNextResponse::top_level_task(task.clone());
368
369        assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
370        assert!(response.task.is_some());
371        assert_eq!(response.task.unwrap().id, 2);
372        assert!(response.reason_code.is_none());
373        assert!(response.message.is_none());
374    }
375
376    #[test]
377    fn test_pick_next_response_no_tasks_in_project() {
378        let response = PickNextResponse::no_tasks_in_project();
379
380        assert_eq!(response.suggestion_type, "NONE");
381        assert!(response.task.is_none());
382        assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
383        assert!(response.message.is_some());
384        assert!(response.message.unwrap().contains("No tasks found"));
385    }
386
387    #[test]
388    fn test_pick_next_response_all_tasks_completed() {
389        let response = PickNextResponse::all_tasks_completed();
390
391        assert_eq!(response.suggestion_type, "NONE");
392        assert!(response.task.is_none());
393        assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
394        assert!(response.message.is_some());
395        assert!(response.message.unwrap().contains("Project Complete"));
396    }
397
398    #[test]
399    fn test_pick_next_response_no_available_todos() {
400        let response = PickNextResponse::no_available_todos();
401
402        assert_eq!(response.suggestion_type, "NONE");
403        assert!(response.task.is_none());
404        assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
405        assert!(response.message.is_some());
406    }
407
408    #[test]
409    fn test_format_as_text_focused_subtask() {
410        let task = create_test_task(1, "Test task", Some(5));
411        let response = PickNextResponse::focused_subtask(task);
412        let text = response.format_as_text();
413
414        assert!(text.contains("Based on your current focus"));
415        assert!(text.contains("[ID: 1]"));
416        assert!(text.contains("[Priority: 5]"));
417        assert!(text.contains("Test task"));
418        assert!(text.contains("ie task start 1"));
419    }
420
421    #[test]
422    fn test_format_as_text_top_level_task() {
423        let task = create_test_task(2, "Top level task", None);
424        let response = PickNextResponse::top_level_task(task);
425        let text = response.format_as_text();
426
427        assert!(text.contains("Based on your current focus"));
428        assert!(text.contains("[ID: 2]"));
429        assert!(text.contains("[Priority: 0]")); // Default priority
430        assert!(text.contains("Top level task"));
431        assert!(text.contains("ie task start 2"));
432    }
433
434    #[test]
435    fn test_format_as_text_no_tasks_in_project() {
436        let response = PickNextResponse::no_tasks_in_project();
437        let text = response.format_as_text();
438
439        assert!(text.contains("[INFO]"));
440        assert!(text.contains("No tasks found"));
441        assert!(text.contains("ie task add"));
442        assert!(text.contains("--priority 1"));
443    }
444
445    #[test]
446    fn test_format_as_text_all_tasks_completed() {
447        let response = PickNextResponse::all_tasks_completed();
448        let text = response.format_as_text();
449
450        assert!(text.contains("[SUCCESS]"));
451        assert!(text.contains("Project Complete"));
452        assert!(text.contains("ie report --since 30d"));
453    }
454
455    #[test]
456    fn test_format_as_text_no_available_todos() {
457        let response = PickNextResponse::no_available_todos();
458        let text = response.format_as_text();
459
460        assert!(text.contains("[INFO]"));
461        assert!(text.contains("No immediate next task"));
462        assert!(text.contains("Possible reasons"));
463        assert!(text.contains("ie task find"));
464    }
465
466    #[test]
467    fn test_error_response_serialization() {
468        use crate::error::IntentError;
469
470        let error = IntentError::TaskNotFound(123);
471        let response = error.to_error_response();
472
473        assert_eq!(response.code, "TASK_NOT_FOUND");
474        assert!(response.error.contains("123"));
475    }
476
477    #[test]
478    fn test_next_step_suggestion_serialization() {
479        let suggestion = NextStepSuggestion::ParentIsReady {
480            message: "Test message".to_string(),
481            parent_task_id: 1,
482            parent_task_name: "Parent".to_string(),
483        };
484
485        let json = serde_json::to_string(&suggestion).unwrap();
486        assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
487        assert!(json.contains("parent_task_id"));
488    }
489
490    #[test]
491    fn test_task_with_events_serialization() {
492        let task = create_test_task(1, "Test", Some(5));
493        let task_with_events = TaskWithEvents {
494            task,
495            events_summary: None,
496        };
497
498        let json = serde_json::to_string(&task_with_events).unwrap();
499        assert!(json.contains("\"id\":1"));
500        assert!(json.contains("\"name\":\"Test\""));
501        // events_summary should be skipped when None
502        assert!(!json.contains("events_summary"));
503    }
504
505    #[test]
506    fn test_report_summary_with_date_range() {
507        let from = Utc::now() - chrono::Duration::days(7);
508        let to = Utc::now();
509
510        let summary = ReportSummary {
511            total_tasks: 10,
512            tasks_by_status: StatusBreakdown {
513                todo: 5,
514                doing: 3,
515                done: 2,
516            },
517            total_events: 20,
518            date_range: Some(DateRange { from, to }),
519        };
520
521        let json = serde_json::to_string(&summary).unwrap();
522        assert!(json.contains("\"total_tasks\":10"));
523        assert!(json.contains("\"total_events\":20"));
524        assert!(json.contains("date_range"));
525    }
526}