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