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/// Task approval record for human task authorization
14/// AI must provide the passphrase to complete a human-owned task
15#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
16pub struct TaskApproval {
17    pub id: i64,
18    pub task_id: i64,
19    pub passphrase: String,
20    pub created_at: DateTime<Utc>,
21    pub expires_at: Option<DateTime<Utc>>,
22}
23
24/// Response for creating a task approval
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ApprovalResponse {
27    pub task_id: i64,
28    pub passphrase: String,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub expires_at: Option<DateTime<Utc>>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
34pub struct Task {
35    pub id: i64,
36    pub parent_id: Option<i64>,
37    pub name: String,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub spec: Option<String>,
40    pub status: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub complexity: Option<i32>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub priority: Option<i32>,
45    pub first_todo_at: Option<DateTime<Utc>>,
46    pub first_doing_at: Option<DateTime<Utc>>,
47    pub first_done_at: Option<DateTime<Utc>>,
48    /// Present progressive form for UI display when task is in_progress
49    /// Example: "Implementing authentication" vs "Implement authentication"
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub active_form: Option<String>,
52    /// Task owner: 'human' (created via CLI/Dashboard) or 'ai' (created via MCP)
53    /// Human tasks require passphrase authorization for AI to complete
54    #[serde(default = "default_owner")]
55    pub owner: String,
56}
57
58fn default_owner() -> String {
59    "human".to_string()
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct TaskWithEvents {
64    #[serde(flatten)]
65    pub task: Task,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub events_summary: Option<EventsSummary>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct EventsSummary {
72    pub total_count: i64,
73    pub recent_events: Vec<Event>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
77pub struct Event {
78    pub id: i64,
79    pub task_id: i64,
80    pub timestamp: DateTime<Utc>,
81    pub log_type: String,
82    pub discussion_data: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
86pub struct WorkspaceState {
87    pub key: String,
88    pub value: String,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Report {
93    pub summary: ReportSummary,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub tasks: Option<Vec<Task>>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub events: Option<Vec<Event>>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ReportSummary {
102    pub total_tasks: i64,
103    pub tasks_by_status: StatusBreakdown,
104    pub total_events: i64,
105    pub date_range: Option<DateRange>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct StatusBreakdown {
110    pub todo: i64,
111    pub doing: i64,
112    pub done: i64,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct DateRange {
117    pub from: DateTime<Utc>,
118    pub to: DateTime<Utc>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct DoneTaskResponse {
123    pub completed_task: Task,
124    pub workspace_status: WorkspaceStatus,
125    pub next_step_suggestion: NextStepSuggestion,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct WorkspaceStatus {
130    pub current_task_id: Option<i64>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(tag = "type")]
135pub enum NextStepSuggestion {
136    #[serde(rename = "PARENT_IS_READY")]
137    ParentIsReady {
138        message: String,
139        parent_task_id: i64,
140        parent_task_name: String,
141    },
142    #[serde(rename = "SIBLING_TASKS_REMAIN")]
143    SiblingTasksRemain {
144        message: String,
145        parent_task_id: i64,
146        parent_task_name: String,
147        remaining_siblings_count: i64,
148    },
149    #[serde(rename = "TOP_LEVEL_TASK_COMPLETED")]
150    TopLevelTaskCompleted {
151        message: String,
152        completed_task_id: i64,
153        completed_task_name: String,
154    },
155    #[serde(rename = "NO_PARENT_CONTEXT")]
156    NoParentContext {
157        message: String,
158        completed_task_id: i64,
159        completed_task_name: String,
160    },
161    #[serde(rename = "WORKSPACE_IS_CLEAR")]
162    WorkspaceIsClear {
163        message: String,
164        completed_task_id: i64,
165    },
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct TaskSearchResult {
170    #[serde(flatten)]
171    pub task: Task,
172    pub match_snippet: String,
173}
174
175/// Unified search result that can represent either a task or event match
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(tag = "result_type")]
178pub enum SearchResult {
179    #[serde(rename = "task")]
180    Task {
181        #[serde(flatten)]
182        task: Task,
183        match_snippet: String,
184        match_field: String, // "name" or "spec"
185    },
186    #[serde(rename = "event")]
187    Event {
188        event: Event,
189        task_chain: Vec<Task>, // Ancestry: [immediate task, parent, grandparent, ...]
190        match_snippet: String,
191    },
192}
193
194/// Paginated search results across tasks and events
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct PaginatedSearchResults {
197    pub results: Vec<SearchResult>,
198    pub total_tasks: i64,
199    pub total_events: i64,
200    pub has_more: bool,
201    pub limit: i64,
202    pub offset: i64,
203}
204
205/// Response for spawn-subtask command - includes subtask and parent info
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct SpawnSubtaskResponse {
208    pub subtask: SubtaskInfo,
209    pub parent_task: ParentTaskInfo,
210}
211
212/// Subtask info for spawn response
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct SubtaskInfo {
215    pub id: i64,
216    pub name: String,
217    pub parent_id: i64,
218    pub status: String,
219}
220
221/// Parent task info for spawn response
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct ParentTaskInfo {
224    pub id: i64,
225    pub name: String,
226}
227
228/// Dependency information for a task
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct TaskDependencies {
231    /// Tasks that must be completed before this task can start
232    pub blocking_tasks: Vec<Task>,
233    /// Tasks that are blocked by this task
234    pub blocked_by_tasks: Vec<Task>,
235}
236
237/// Response for task_context - provides the complete family tree of a task
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct TaskContext {
240    pub task: Task,
241    pub ancestors: Vec<Task>,
242    pub siblings: Vec<Task>,
243    pub children: Vec<Task>,
244    pub dependencies: TaskDependencies,
245}
246
247/// Sort order for task queries
248#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum TaskSortBy {
251    /// Legacy: ORDER BY id ASC (backward compatible)
252    Id,
253    /// ORDER BY priority ASC, complexity ASC, id ASC
254    Priority,
255    /// ORDER BY first_doing_at DESC NULLS LAST, first_todo_at DESC NULLS LAST, id ASC
256    Time,
257    /// Focus-aware: current focused task → doing tasks → todo tasks
258    #[default]
259    FocusAware,
260}
261
262/// Paginated task query results
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct PaginatedTasks {
265    pub tasks: Vec<Task>,
266    pub total_count: i64,
267    pub has_more: bool,
268    pub limit: i64,
269    pub offset: i64,
270}
271
272/// Workspace statistics (aggregated counts without loading tasks)
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct WorkspaceStats {
275    pub total_tasks: i64,
276    pub todo: i64,
277    pub doing: i64,
278    pub done: i64,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct PickNextResponse {
283    pub suggestion_type: String,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub task: Option<Task>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub reason_code: Option<String>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub message: Option<String>,
290}
291
292impl PickNextResponse {
293    /// Create a response for focused subtask suggestion
294    pub fn focused_subtask(task: Task) -> Self {
295        Self {
296            suggestion_type: "FOCUSED_SUB_TASK".to_string(),
297            task: Some(task),
298            reason_code: None,
299            message: None,
300        }
301    }
302
303    /// Create a response for top-level task suggestion
304    pub fn top_level_task(task: Task) -> Self {
305        Self {
306            suggestion_type: "TOP_LEVEL_TASK".to_string(),
307            task: Some(task),
308            reason_code: None,
309            message: None,
310        }
311    }
312
313    /// Create a response for no tasks in project
314    pub fn no_tasks_in_project() -> Self {
315        Self {
316            suggestion_type: "NONE".to_string(),
317            task: None,
318            reason_code: Some("NO_TASKS_IN_PROJECT".to_string()),
319            message: Some(
320                "No tasks found in this project. Your intent backlog is empty.".to_string(),
321            ),
322        }
323    }
324
325    /// Create a response for all tasks completed
326    pub fn all_tasks_completed() -> Self {
327        Self {
328            suggestion_type: "NONE".to_string(),
329            task: None,
330            reason_code: Some("ALL_TASKS_COMPLETED".to_string()),
331            message: Some("Project Complete! All intents have been realized.".to_string()),
332        }
333    }
334
335    /// Create a response for no available todos
336    pub fn no_available_todos() -> Self {
337        Self {
338            suggestion_type: "NONE".to_string(),
339            task: None,
340            reason_code: Some("NO_AVAILABLE_TODOS".to_string()),
341            message: Some("No immediate next task found based on the current context.".to_string()),
342        }
343    }
344
345    /// Format response as human-readable text
346    pub fn format_as_text(&self) -> String {
347        match self.suggestion_type.as_str() {
348            "FOCUSED_SUB_TASK" | "TOP_LEVEL_TASK" => {
349                if let Some(task) = &self.task {
350                    format!(
351                        "Based on your current focus, the recommended next task is:\n\n\
352                        [ID: {}] [Priority: {}] [Status: {}]\n\
353                        Name: {}\n\n\
354                        To start working on it, run:\n  ie task start {}",
355                        task.id,
356                        task.priority.unwrap_or(0),
357                        task.status,
358                        task.name,
359                        task.id
360                    )
361                } else {
362                    "[ERROR] Invalid response: task is missing".to_string()
363                }
364            },
365            "NONE" => {
366                let reason_code = self.reason_code.as_deref().unwrap_or("UNKNOWN");
367                let message = self.message.as_deref().unwrap_or("No tasks found");
368
369                match reason_code {
370                    "NO_TASKS_IN_PROJECT" => {
371                        format!(
372                            "[INFO] {}\n\n\
373                            To get started, capture your first high-level intent:\n  \
374                            ie task add --name \"Setup initial project structure\" --priority 1",
375                            message
376                        )
377                    },
378                    "ALL_TASKS_COMPLETED" => {
379                        format!(
380                            "[SUCCESS] {}\n\n\
381                            You can review the accomplishments of the last 30 days with:\n  \
382                            ie report --since 30d",
383                            message
384                        )
385                    },
386                    "NO_AVAILABLE_TODOS" => {
387                        format!(
388                            "[INFO] {}\n\n\
389                            Possible reasons:\n\
390                            - All available 'todo' tasks are part of larger epics that have not been started yet.\n\
391                            - You are not currently focused on a task that has 'todo' sub-tasks.\n\n\
392                            To see all available top-level tasks you can start, run:\n  \
393                            ie task find --parent NULL --status todo",
394                            message
395                        )
396                    },
397                    _ => format!("[INFO] {}", message),
398                }
399            },
400            _ => "[ERROR] Unknown suggestion type".to_string(),
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    fn create_test_task(id: i64, name: &str, priority: Option<i32>) -> Task {
410        Task {
411            id,
412            parent_id: None,
413            name: name.to_string(),
414            spec: None,
415            status: "todo".to_string(),
416            complexity: None,
417            priority,
418            first_todo_at: None,
419            first_doing_at: None,
420            first_done_at: None,
421            active_form: None,
422            owner: "human".to_string(),
423        }
424    }
425
426    #[test]
427    fn test_pick_next_response_focused_subtask() {
428        let task = create_test_task(1, "Test task", Some(5));
429        let response = PickNextResponse::focused_subtask(task.clone());
430
431        assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
432        assert!(response.task.is_some());
433        assert_eq!(response.task.unwrap().id, 1);
434        assert!(response.reason_code.is_none());
435        assert!(response.message.is_none());
436    }
437
438    #[test]
439    fn test_pick_next_response_top_level_task() {
440        let task = create_test_task(2, "Top level task", Some(3));
441        let response = PickNextResponse::top_level_task(task.clone());
442
443        assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
444        assert!(response.task.is_some());
445        assert_eq!(response.task.unwrap().id, 2);
446        assert!(response.reason_code.is_none());
447        assert!(response.message.is_none());
448    }
449
450    #[test]
451    fn test_pick_next_response_no_tasks_in_project() {
452        let response = PickNextResponse::no_tasks_in_project();
453
454        assert_eq!(response.suggestion_type, "NONE");
455        assert!(response.task.is_none());
456        assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
457        assert!(response.message.is_some());
458        assert!(response.message.unwrap().contains("No tasks found"));
459    }
460
461    #[test]
462    fn test_pick_next_response_all_tasks_completed() {
463        let response = PickNextResponse::all_tasks_completed();
464
465        assert_eq!(response.suggestion_type, "NONE");
466        assert!(response.task.is_none());
467        assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
468        assert!(response.message.is_some());
469        assert!(response.message.unwrap().contains("Project Complete"));
470    }
471
472    #[test]
473    fn test_pick_next_response_no_available_todos() {
474        let response = PickNextResponse::no_available_todos();
475
476        assert_eq!(response.suggestion_type, "NONE");
477        assert!(response.task.is_none());
478        assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
479        assert!(response.message.is_some());
480    }
481
482    #[test]
483    fn test_format_as_text_focused_subtask() {
484        let task = create_test_task(1, "Test task", Some(5));
485        let response = PickNextResponse::focused_subtask(task);
486        let text = response.format_as_text();
487
488        assert!(text.contains("Based on your current focus"));
489        assert!(text.contains("[ID: 1]"));
490        assert!(text.contains("[Priority: 5]"));
491        assert!(text.contains("Test task"));
492        assert!(text.contains("ie task start 1"));
493    }
494
495    #[test]
496    fn test_format_as_text_top_level_task() {
497        let task = create_test_task(2, "Top level task", None);
498        let response = PickNextResponse::top_level_task(task);
499        let text = response.format_as_text();
500
501        assert!(text.contains("Based on your current focus"));
502        assert!(text.contains("[ID: 2]"));
503        assert!(text.contains("[Priority: 0]")); // Default priority
504        assert!(text.contains("Top level task"));
505        assert!(text.contains("ie task start 2"));
506    }
507
508    #[test]
509    fn test_format_as_text_no_tasks_in_project() {
510        let response = PickNextResponse::no_tasks_in_project();
511        let text = response.format_as_text();
512
513        assert!(text.contains("[INFO]"));
514        assert!(text.contains("No tasks found"));
515        assert!(text.contains("ie task add"));
516        assert!(text.contains("--priority 1"));
517    }
518
519    #[test]
520    fn test_format_as_text_all_tasks_completed() {
521        let response = PickNextResponse::all_tasks_completed();
522        let text = response.format_as_text();
523
524        assert!(text.contains("[SUCCESS]"));
525        assert!(text.contains("Project Complete"));
526        assert!(text.contains("ie report --since 30d"));
527    }
528
529    #[test]
530    fn test_format_as_text_no_available_todos() {
531        let response = PickNextResponse::no_available_todos();
532        let text = response.format_as_text();
533
534        assert!(text.contains("[INFO]"));
535        assert!(text.contains("No immediate next task"));
536        assert!(text.contains("Possible reasons"));
537        assert!(text.contains("ie task find"));
538    }
539
540    #[test]
541    fn test_error_response_serialization() {
542        use crate::error::IntentError;
543
544        let error = IntentError::TaskNotFound(123);
545        let response = error.to_error_response();
546
547        assert_eq!(response.code, "TASK_NOT_FOUND");
548        assert!(response.error.contains("123"));
549    }
550
551    #[test]
552    fn test_next_step_suggestion_serialization() {
553        let suggestion = NextStepSuggestion::ParentIsReady {
554            message: "Test message".to_string(),
555            parent_task_id: 1,
556            parent_task_name: "Parent".to_string(),
557        };
558
559        let json = serde_json::to_string(&suggestion).unwrap();
560        assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
561        assert!(json.contains("parent_task_id"));
562    }
563
564    #[test]
565    fn test_task_with_events_serialization() {
566        let task = create_test_task(1, "Test", Some(5));
567        let task_with_events = TaskWithEvents {
568            task,
569            events_summary: None,
570        };
571
572        let json = serde_json::to_string(&task_with_events).unwrap();
573        assert!(json.contains("\"id\":1"));
574        assert!(json.contains("\"name\":\"Test\""));
575        // events_summary should be skipped when None
576        assert!(!json.contains("events_summary"));
577    }
578
579    #[test]
580    fn test_report_summary_with_date_range() {
581        let from = Utc::now() - chrono::Duration::days(7);
582        let to = Utc::now();
583
584        let summary = ReportSummary {
585            total_tasks: 10,
586            tasks_by_status: StatusBreakdown {
587                todo: 5,
588                doing: 3,
589                done: 2,
590            },
591            total_events: 20,
592            date_range: Some(DateRange { from, to }),
593        };
594
595        let json = serde_json::to_string(&summary).unwrap();
596        assert!(json.contains("\"total_tasks\":10"));
597        assert!(json.contains("\"total_events\":20"));
598        assert!(json.contains("date_range"));
599    }
600}