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