1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sqlx::FromRow;
4
5mod datetime_format {
7 use chrono::{DateTime, Utc};
8 use serde::{self, Deserialize, Deserializer, Serializer};
9
10 const FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
11
12 pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
13 where
14 S: Serializer,
15 {
16 let s = date.format(FORMAT).to_string();
17 serializer.serialize_str(&s)
18 }
19
20 pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
21 where
22 D: Deserializer<'de>,
23 {
24 let s = String::deserialize(deserializer)?;
25 DateTime::parse_from_str(&s, FORMAT)
27 .map(|dt| dt.with_timezone(&Utc))
28 .or_else(|_| DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&Utc)))
29 .map_err(serde::de::Error::custom)
30 }
31}
32
33mod option_datetime_format {
35 use chrono::{DateTime, Utc};
36 use serde::{self, Deserialize, Deserializer, Serializer};
37
38 const FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
39
40 pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
41 where
42 S: Serializer,
43 {
44 match date {
45 Some(dt) => {
46 let s = dt.format(FORMAT).to_string();
47 serializer.serialize_some(&s)
48 },
49 None => serializer.serialize_none(),
50 }
51 }
52
53 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
54 where
55 D: Deserializer<'de>,
56 {
57 let opt: Option<String> = Option::deserialize(deserializer)?;
58 match opt {
59 Some(s) => DateTime::parse_from_str(&s, FORMAT)
60 .map(|dt| Some(dt.with_timezone(&Utc)))
61 .or_else(|_| {
62 DateTime::parse_from_rfc3339(&s).map(|dt| Some(dt.with_timezone(&Utc)))
63 })
64 .map_err(serde::de::Error::custom),
65 None => Ok(None),
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
71pub struct Dependency {
72 pub id: i64,
73 pub blocking_task_id: i64,
74 pub blocked_task_id: i64,
75 #[serde(with = "datetime_format")]
76 pub created_at: DateTime<Utc>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
82pub struct TaskApproval {
83 pub id: i64,
84 pub task_id: i64,
85 pub passphrase: String,
86 #[serde(with = "datetime_format")]
87 pub created_at: DateTime<Utc>,
88 #[serde(with = "option_datetime_format")]
89 pub expires_at: Option<DateTime<Utc>>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ApprovalResponse {
95 pub task_id: i64,
96 pub passphrase: String,
97 #[serde(
98 skip_serializing_if = "Option::is_none",
99 with = "option_datetime_format"
100 )]
101 pub expires_at: Option<DateTime<Utc>>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, FromRow, PartialEq)]
105pub struct Task {
106 pub id: i64,
107 pub parent_id: Option<i64>,
108 pub name: String,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub spec: Option<String>,
111 pub status: String,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub complexity: Option<i32>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub priority: Option<i32>,
116 #[serde(with = "option_datetime_format")]
117 pub first_todo_at: Option<DateTime<Utc>>,
118 #[serde(with = "option_datetime_format")]
119 pub first_doing_at: Option<DateTime<Utc>>,
120 #[serde(with = "option_datetime_format")]
121 pub first_done_at: Option<DateTime<Utc>>,
122 #[serde(skip_serializing_if = "Option::is_none")]
125 pub active_form: Option<String>,
126 #[serde(default = "default_owner")]
129 pub owner: String,
130}
131
132fn default_owner() -> String {
133 "human".to_string()
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137pub struct TaskWithEvents {
138 #[serde(flatten)]
139 pub task: Task,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub events_summary: Option<EventsSummary>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145pub struct EventsSummary {
146 pub total_count: i64,
147 pub recent_events: Vec<Event>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, FromRow, PartialEq)]
151pub struct Event {
152 pub id: i64,
153 pub task_id: i64,
154 #[serde(with = "datetime_format")]
155 pub timestamp: DateTime<Utc>,
156 pub log_type: String,
157 pub discussion_data: String,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
161pub struct WorkspaceState {
162 pub key: String,
163 pub value: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Report {
168 pub summary: ReportSummary,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub tasks: Option<Vec<Task>>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub events: Option<Vec<Event>>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct ReportSummary {
177 pub total_tasks: i64,
178 pub tasks_by_status: StatusBreakdown,
179 pub total_events: i64,
180 pub date_range: Option<DateRange>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct StatusBreakdown {
185 pub todo: i64,
186 pub doing: i64,
187 pub done: i64,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct DateRange {
192 #[serde(with = "datetime_format")]
193 pub from: DateTime<Utc>,
194 #[serde(with = "datetime_format")]
195 pub to: DateTime<Utc>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct DoneTaskResponse {
200 pub completed_task: Task,
201 pub workspace_status: WorkspaceStatus,
202 pub next_step_suggestion: NextStepSuggestion,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct WorkspaceStatus {
207 pub current_task_id: Option<i64>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(tag = "type")]
212pub enum NextStepSuggestion {
213 #[serde(rename = "PARENT_IS_READY")]
214 ParentIsReady {
215 message: String,
216 parent_task_id: i64,
217 parent_task_name: String,
218 },
219 #[serde(rename = "SIBLING_TASKS_REMAIN")]
220 SiblingTasksRemain {
221 message: String,
222 parent_task_id: i64,
223 parent_task_name: String,
224 remaining_siblings_count: i64,
225 },
226 #[serde(rename = "TOP_LEVEL_TASK_COMPLETED")]
227 TopLevelTaskCompleted {
228 message: String,
229 completed_task_id: i64,
230 completed_task_name: String,
231 },
232 #[serde(rename = "NO_PARENT_CONTEXT")]
233 NoParentContext {
234 message: String,
235 completed_task_id: i64,
236 completed_task_name: String,
237 },
238 #[serde(rename = "WORKSPACE_IS_CLEAR")]
239 WorkspaceIsClear {
240 message: String,
241 completed_task_id: i64,
242 },
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct TaskSearchResult {
247 #[serde(flatten)]
248 pub task: Task,
249 pub match_snippet: String,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(tag = "result_type")]
255pub enum SearchResult {
256 #[serde(rename = "task")]
257 Task {
258 #[serde(flatten)]
259 task: Task,
260 match_snippet: String,
261 match_field: String, },
263 #[serde(rename = "event")]
264 Event {
265 event: Event,
266 task_chain: Vec<Task>, match_snippet: String,
268 },
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct PaginatedSearchResults {
274 pub results: Vec<SearchResult>,
275 pub total_tasks: i64,
276 pub total_events: i64,
277 pub has_more: bool,
278 pub limit: i64,
279 pub offset: i64,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct SpawnSubtaskResponse {
285 pub subtask: SubtaskInfo,
286 pub parent_task: ParentTaskInfo,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct SubtaskInfo {
292 pub id: i64,
293 pub name: String,
294 pub parent_id: i64,
295 pub status: String,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ParentTaskInfo {
301 pub id: i64,
302 pub name: String,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct TaskDependencies {
308 pub blocking_tasks: Vec<Task>,
310 pub blocked_by_tasks: Vec<Task>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct TaskContext {
317 pub task: Task,
318 pub ancestors: Vec<Task>,
319 pub siblings: Vec<Task>,
320 pub children: Vec<Task>,
321 pub dependencies: TaskDependencies,
322}
323
324#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
326#[serde(rename_all = "snake_case")]
327pub enum TaskSortBy {
328 Id,
330 Priority,
332 Time,
334 #[default]
336 FocusAware,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct PaginatedTasks {
342 pub tasks: Vec<Task>,
343 pub total_count: i64,
344 pub has_more: bool,
345 pub limit: i64,
346 pub offset: i64,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct WorkspaceStats {
352 pub total_tasks: i64,
353 pub todo: i64,
354 pub doing: i64,
355 pub done: i64,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct PickNextResponse {
360 pub suggestion_type: String,
361 #[serde(skip_serializing_if = "Option::is_none")]
362 pub task: Option<Task>,
363 #[serde(skip_serializing_if = "Option::is_none")]
364 pub reason_code: Option<String>,
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub message: Option<String>,
367}
368
369impl PickNextResponse {
370 pub fn focused_subtask(task: Task) -> Self {
372 Self {
373 suggestion_type: "FOCUSED_SUB_TASK".to_string(),
374 task: Some(task),
375 reason_code: None,
376 message: None,
377 }
378 }
379
380 pub fn top_level_task(task: Task) -> Self {
382 Self {
383 suggestion_type: "TOP_LEVEL_TASK".to_string(),
384 task: Some(task),
385 reason_code: None,
386 message: None,
387 }
388 }
389
390 pub fn no_tasks_in_project() -> Self {
392 Self {
393 suggestion_type: "NONE".to_string(),
394 task: None,
395 reason_code: Some("NO_TASKS_IN_PROJECT".to_string()),
396 message: Some(
397 "No tasks found in this project. Your intent backlog is empty.".to_string(),
398 ),
399 }
400 }
401
402 pub fn all_tasks_completed() -> Self {
404 Self {
405 suggestion_type: "NONE".to_string(),
406 task: None,
407 reason_code: Some("ALL_TASKS_COMPLETED".to_string()),
408 message: Some("Project Complete! All intents have been realized.".to_string()),
409 }
410 }
411
412 pub fn no_available_todos() -> Self {
414 Self {
415 suggestion_type: "NONE".to_string(),
416 task: None,
417 reason_code: Some("NO_AVAILABLE_TODOS".to_string()),
418 message: Some("No immediate next task found based on the current context.".to_string()),
419 }
420 }
421
422 pub fn format_as_text(&self) -> String {
424 match self.suggestion_type.as_str() {
425 "FOCUSED_SUB_TASK" | "TOP_LEVEL_TASK" => {
426 if let Some(task) = &self.task {
427 format!(
428 "Based on your current focus, the recommended next task is:\n\n\
429 [ID: {}] [Priority: {}] [Status: {}]\n\
430 Name: {}\n\n\
431 To start working on it, run:\n ie task start {}",
432 task.id,
433 task.priority.unwrap_or(0),
434 task.status,
435 task.name,
436 task.id
437 )
438 } else {
439 "[ERROR] Invalid response: task is missing".to_string()
440 }
441 },
442 "NONE" => {
443 let reason_code = self.reason_code.as_deref().unwrap_or("UNKNOWN");
444 let message = self.message.as_deref().unwrap_or("No tasks found");
445
446 match reason_code {
447 "NO_TASKS_IN_PROJECT" => {
448 format!(
449 "[INFO] {}\n\n\
450 To get started, capture your first high-level intent:\n \
451 ie task add --name \"Setup initial project structure\" --priority 1",
452 message
453 )
454 },
455 "ALL_TASKS_COMPLETED" => {
456 format!(
457 "[SUCCESS] {}\n\n\
458 You can review the accomplishments of the last 30 days with:\n \
459 ie report --since 30d",
460 message
461 )
462 },
463 "NO_AVAILABLE_TODOS" => {
464 format!(
465 "[INFO] {}\n\n\
466 Possible reasons:\n\
467 - All available 'todo' tasks are part of larger epics that have not been started yet.\n\
468 - You are not currently focused on a task that has 'todo' sub-tasks.\n\n\
469 To see all available top-level tasks you can start, run:\n \
470 ie task find --parent NULL --status todo",
471 message
472 )
473 },
474 _ => format!("[INFO] {}", message),
475 }
476 },
477 _ => "[ERROR] Unknown suggestion type".to_string(),
478 }
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484pub struct TaskBrief {
485 pub id: i64,
486 pub name: String,
487 pub status: String,
488 #[serde(skip_serializing_if = "Option::is_none")]
489 pub parent_id: Option<i64>,
490 #[serde(default)]
492 pub has_spec: bool,
493}
494
495impl From<&Task> for TaskBrief {
496 fn from(task: &Task) -> Self {
497 Self {
498 id: task.id,
499 name: task.name.clone(),
500 status: task.status.clone(),
501 parent_id: task.parent_id,
502 has_spec: task
503 .spec
504 .as_ref()
505 .map(|s| !s.trim().is_empty())
506 .unwrap_or(false),
507 }
508 }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct StatusResponse {
514 pub focused_task: Task,
516 pub ancestors: Vec<Task>,
518 pub siblings: Vec<TaskBrief>,
520 pub descendants: Vec<TaskBrief>,
522 #[serde(skip_serializing_if = "Option::is_none")]
524 pub events: Option<Vec<Event>>,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct NoFocusResponse {
530 pub message: String,
531 pub root_tasks: Vec<TaskBrief>,
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 fn create_test_task(id: i64, name: &str, priority: Option<i32>) -> Task {
540 Task {
541 id,
542 parent_id: None,
543 name: name.to_string(),
544 spec: None,
545 status: "todo".to_string(),
546 complexity: None,
547 priority,
548 first_todo_at: None,
549 first_doing_at: None,
550 first_done_at: None,
551 active_form: None,
552 owner: "human".to_string(),
553 }
554 }
555
556 #[test]
557 fn test_pick_next_response_focused_subtask() {
558 let task = create_test_task(1, "Test task", Some(5));
559 let response = PickNextResponse::focused_subtask(task.clone());
560
561 assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
562 assert!(response.task.is_some());
563 assert_eq!(response.task.unwrap().id, 1);
564 assert!(response.reason_code.is_none());
565 assert!(response.message.is_none());
566 }
567
568 #[test]
569 fn test_pick_next_response_top_level_task() {
570 let task = create_test_task(2, "Top level task", Some(3));
571 let response = PickNextResponse::top_level_task(task.clone());
572
573 assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
574 assert!(response.task.is_some());
575 assert_eq!(response.task.unwrap().id, 2);
576 assert!(response.reason_code.is_none());
577 assert!(response.message.is_none());
578 }
579
580 #[test]
581 fn test_pick_next_response_no_tasks_in_project() {
582 let response = PickNextResponse::no_tasks_in_project();
583
584 assert_eq!(response.suggestion_type, "NONE");
585 assert!(response.task.is_none());
586 assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
587 assert!(response.message.is_some());
588 assert!(response.message.unwrap().contains("No tasks found"));
589 }
590
591 #[test]
592 fn test_pick_next_response_all_tasks_completed() {
593 let response = PickNextResponse::all_tasks_completed();
594
595 assert_eq!(response.suggestion_type, "NONE");
596 assert!(response.task.is_none());
597 assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
598 assert!(response.message.is_some());
599 assert!(response.message.unwrap().contains("Project Complete"));
600 }
601
602 #[test]
603 fn test_pick_next_response_no_available_todos() {
604 let response = PickNextResponse::no_available_todos();
605
606 assert_eq!(response.suggestion_type, "NONE");
607 assert!(response.task.is_none());
608 assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
609 assert!(response.message.is_some());
610 }
611
612 #[test]
613 fn test_format_as_text_focused_subtask() {
614 let task = create_test_task(1, "Test task", Some(5));
615 let response = PickNextResponse::focused_subtask(task);
616 let text = response.format_as_text();
617
618 assert!(text.contains("Based on your current focus"));
619 assert!(text.contains("[ID: 1]"));
620 assert!(text.contains("[Priority: 5]"));
621 assert!(text.contains("Test task"));
622 assert!(text.contains("ie task start 1"));
623 }
624
625 #[test]
626 fn test_format_as_text_top_level_task() {
627 let task = create_test_task(2, "Top level task", None);
628 let response = PickNextResponse::top_level_task(task);
629 let text = response.format_as_text();
630
631 assert!(text.contains("Based on your current focus"));
632 assert!(text.contains("[ID: 2]"));
633 assert!(text.contains("[Priority: 0]")); assert!(text.contains("Top level task"));
635 assert!(text.contains("ie task start 2"));
636 }
637
638 #[test]
639 fn test_format_as_text_no_tasks_in_project() {
640 let response = PickNextResponse::no_tasks_in_project();
641 let text = response.format_as_text();
642
643 assert!(text.contains("[INFO]"));
644 assert!(text.contains("No tasks found"));
645 assert!(text.contains("ie task add"));
646 assert!(text.contains("--priority 1"));
647 }
648
649 #[test]
650 fn test_format_as_text_all_tasks_completed() {
651 let response = PickNextResponse::all_tasks_completed();
652 let text = response.format_as_text();
653
654 assert!(text.contains("[SUCCESS]"));
655 assert!(text.contains("Project Complete"));
656 assert!(text.contains("ie report --since 30d"));
657 }
658
659 #[test]
660 fn test_format_as_text_no_available_todos() {
661 let response = PickNextResponse::no_available_todos();
662 let text = response.format_as_text();
663
664 assert!(text.contains("[INFO]"));
665 assert!(text.contains("No immediate next task"));
666 assert!(text.contains("Possible reasons"));
667 assert!(text.contains("ie task find"));
668 }
669
670 #[test]
671 fn test_error_response_serialization() {
672 use crate::error::IntentError;
673
674 let error = IntentError::TaskNotFound(123);
675 let response = error.to_error_response();
676
677 assert_eq!(response.code, "TASK_NOT_FOUND");
678 assert!(response.error.contains("123"));
679 }
680
681 #[test]
682 fn test_next_step_suggestion_serialization() {
683 let suggestion = NextStepSuggestion::ParentIsReady {
684 message: "Test message".to_string(),
685 parent_task_id: 1,
686 parent_task_name: "Parent".to_string(),
687 };
688
689 let json = serde_json::to_string(&suggestion).unwrap();
690 assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
691 assert!(json.contains("parent_task_id"));
692 }
693
694 #[test]
695 fn test_task_with_events_serialization() {
696 let task = create_test_task(1, "Test", Some(5));
697 let task_with_events = TaskWithEvents {
698 task,
699 events_summary: None,
700 };
701
702 let json = serde_json::to_string(&task_with_events).unwrap();
703 assert!(json.contains("\"id\":1"));
704 assert!(json.contains("\"name\":\"Test\""));
705 assert!(!json.contains("events_summary"));
707 }
708
709 #[test]
710 fn test_report_summary_with_date_range() {
711 let from = Utc::now() - chrono::Duration::days(7);
712 let to = Utc::now();
713
714 let summary = ReportSummary {
715 total_tasks: 10,
716 tasks_by_status: StatusBreakdown {
717 todo: 5,
718 doing: 3,
719 done: 2,
720 },
721 total_events: 20,
722 date_range: Some(DateRange { from, to }),
723 };
724
725 let json = serde_json::to_string(&summary).unwrap();
726 assert!(json.contains("\"total_tasks\":10"));
727 assert!(json.contains("\"total_events\":20"));
728 assert!(json.contains("date_range"));
729 }
730}