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)]
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#[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 #[serde(skip_serializing_if = "Option::is_none")]
51 pub active_form: Option<String>,
52 #[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#[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, },
186 #[serde(rename = "event")]
187 Event {
188 event: Event,
189 task_chain: Vec<Task>, match_snippet: String,
191 },
192}
193
194#[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#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct SpawnSubtaskResponse {
208 pub subtask: SubtaskInfo,
209 pub parent_task: ParentTaskInfo,
210}
211
212#[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#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct ParentTaskInfo {
224 pub id: i64,
225 pub name: String,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct TaskDependencies {
231 pub blocking_tasks: Vec<Task>,
233 pub blocked_by_tasks: Vec<Task>,
235}
236
237#[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#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum TaskSortBy {
251 Id,
253 Priority,
255 Time,
257 #[default]
259 FocusAware,
260}
261
262#[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#[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 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 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 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 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 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 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]")); 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 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}