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")]
128 pub owner: String,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub metadata: Option<String>,
132}
133
134fn default_owner() -> String {
135 "human".to_string()
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
139pub struct TaskWithEvents {
140 #[serde(flatten)]
141 pub task: Task,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub events_summary: Option<EventsSummary>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct EventsSummary {
148 pub total_count: i64,
149 pub recent_events: Vec<Event>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, FromRow, PartialEq)]
153pub struct Event {
154 pub id: i64,
155 pub task_id: i64,
156 #[serde(with = "datetime_format")]
157 pub timestamp: DateTime<Utc>,
158 pub log_type: String,
159 pub discussion_data: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
163pub struct WorkspaceState {
164 pub key: String,
165 pub value: String,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Report {
170 pub summary: ReportSummary,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub tasks: Option<Vec<Task>>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub events: Option<Vec<Event>>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ReportSummary {
179 pub total_tasks: i64,
180 pub tasks_by_status: StatusBreakdown,
181 pub total_events: i64,
182 pub date_range: Option<DateRange>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct StatusBreakdown {
187 pub todo: i64,
188 pub doing: i64,
189 pub done: i64,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct DateRange {
194 #[serde(with = "datetime_format")]
195 pub from: DateTime<Utc>,
196 #[serde(with = "datetime_format")]
197 pub to: DateTime<Utc>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct DoneTaskResponse {
202 pub completed_task: Task,
203 pub workspace_status: WorkspaceStatus,
204 pub next_step_suggestion: NextStepSuggestion,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct WorkspaceStatus {
209 pub current_task_id: Option<i64>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(tag = "type")]
214pub enum NextStepSuggestion {
215 #[serde(rename = "PARENT_IS_READY")]
216 ParentIsReady {
217 message: String,
218 parent_task_id: i64,
219 parent_task_name: String,
220 },
221 #[serde(rename = "SIBLING_TASKS_REMAIN")]
222 SiblingTasksRemain {
223 message: String,
224 parent_task_id: i64,
225 parent_task_name: String,
226 remaining_siblings_count: i64,
227 },
228 #[serde(rename = "TOP_LEVEL_TASK_COMPLETED")]
229 TopLevelTaskCompleted {
230 message: String,
231 completed_task_id: i64,
232 completed_task_name: String,
233 },
234 #[serde(rename = "NO_PARENT_CONTEXT")]
235 NoParentContext {
236 message: String,
237 completed_task_id: i64,
238 completed_task_name: String,
239 },
240 #[serde(rename = "WORKSPACE_IS_CLEAR")]
241 WorkspaceIsClear {
242 message: String,
243 completed_task_id: i64,
244 },
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct TaskSearchResult {
249 #[serde(flatten)]
250 pub task: Task,
251 pub match_snippet: String,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(tag = "result_type")]
257pub enum SearchResult {
258 #[serde(rename = "task")]
259 Task {
260 #[serde(flatten)]
261 task: Task,
262 match_snippet: String,
263 match_field: String, },
265 #[serde(rename = "event")]
266 Event {
267 event: Event,
268 task_chain: Vec<Task>, match_snippet: String,
270 },
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct PaginatedSearchResults {
276 pub results: Vec<SearchResult>,
277 pub total_tasks: i64,
278 pub total_events: i64,
279 pub has_more: bool,
280 pub limit: i64,
281 pub offset: i64,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct SpawnSubtaskResponse {
287 pub subtask: SubtaskInfo,
288 pub parent_task: ParentTaskInfo,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct SubtaskInfo {
294 pub id: i64,
295 pub name: String,
296 pub parent_id: i64,
297 pub status: String,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct ParentTaskInfo {
303 pub id: i64,
304 pub name: String,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct TaskDependencies {
310 pub blocking_tasks: Vec<Task>,
312 pub blocked_by_tasks: Vec<Task>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct TaskContext {
319 pub task: Task,
320 pub ancestors: Vec<Task>,
321 pub siblings: Vec<Task>,
322 pub children: Vec<Task>,
323 pub dependencies: TaskDependencies,
324}
325
326#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
328#[serde(rename_all = "snake_case")]
329pub enum TaskSortBy {
330 Id,
332 Priority,
334 Time,
336 #[default]
338 FocusAware,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct PaginatedTasks {
344 pub tasks: Vec<Task>,
345 pub total_count: i64,
346 pub has_more: bool,
347 pub limit: i64,
348 pub offset: i64,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct WorkspaceStats {
354 pub total_tasks: i64,
355 pub todo: i64,
356 pub doing: i64,
357 pub done: i64,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct PickNextResponse {
362 pub suggestion_type: String,
363 #[serde(skip_serializing_if = "Option::is_none")]
364 pub task: Option<Task>,
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub reason_code: Option<String>,
367 #[serde(skip_serializing_if = "Option::is_none")]
368 pub message: Option<String>,
369}
370
371impl PickNextResponse {
372 pub fn focused_subtask(task: Task) -> Self {
374 Self {
375 suggestion_type: "FOCUSED_SUB_TASK".to_string(),
376 task: Some(task),
377 reason_code: None,
378 message: None,
379 }
380 }
381
382 pub fn top_level_task(task: Task) -> Self {
384 Self {
385 suggestion_type: "TOP_LEVEL_TASK".to_string(),
386 task: Some(task),
387 reason_code: None,
388 message: None,
389 }
390 }
391
392 pub fn no_tasks_in_project() -> Self {
394 Self {
395 suggestion_type: "NONE".to_string(),
396 task: None,
397 reason_code: Some("NO_TASKS_IN_PROJECT".to_string()),
398 message: Some(
399 "No tasks found in this project. Your intent backlog is empty.".to_string(),
400 ),
401 }
402 }
403
404 pub fn all_tasks_completed() -> Self {
406 Self {
407 suggestion_type: "NONE".to_string(),
408 task: None,
409 reason_code: Some("ALL_TASKS_COMPLETED".to_string()),
410 message: Some("Project Complete! All intents have been realized.".to_string()),
411 }
412 }
413
414 pub fn no_available_todos() -> Self {
416 Self {
417 suggestion_type: "NONE".to_string(),
418 task: None,
419 reason_code: Some("NO_AVAILABLE_TODOS".to_string()),
420 message: Some("No immediate next task found based on the current context.".to_string()),
421 }
422 }
423
424 pub fn format_as_text(&self) -> String {
426 match self.suggestion_type.as_str() {
427 "FOCUSED_SUB_TASK" | "TOP_LEVEL_TASK" => {
428 if let Some(task) = &self.task {
429 format!(
430 "Based on your current focus, the recommended next task is:\n\n\
431 [ID: {}] [Priority: {}] [Status: {}]\n\
432 Name: {}\n\n\
433 To start working on it, run:\n ie task start {}",
434 task.id,
435 task.priority.unwrap_or(0),
436 task.status,
437 task.name,
438 task.id
439 )
440 } else {
441 "[ERROR] Invalid response: task is missing".to_string()
442 }
443 },
444 "NONE" => {
445 let reason_code = self.reason_code.as_deref().unwrap_or("UNKNOWN");
446 let message = self.message.as_deref().unwrap_or("No tasks found");
447
448 match reason_code {
449 "NO_TASKS_IN_PROJECT" => {
450 format!(
451 "[INFO] {}\n\n\
452 To get started, capture your first high-level intent:\n \
453 ie task add --name \"Setup initial project structure\" --priority 1",
454 message
455 )
456 },
457 "ALL_TASKS_COMPLETED" => {
458 format!(
459 "[SUCCESS] {}\n\n\
460 You can review the accomplishments of the last 30 days with:\n \
461 ie report --since 30d",
462 message
463 )
464 },
465 "NO_AVAILABLE_TODOS" => {
466 format!(
467 "[INFO] {}\n\n\
468 Possible reasons:\n\
469 - All available 'todo' tasks are part of larger epics that have not been started yet.\n\
470 - You are not currently focused on a task that has 'todo' sub-tasks.\n\n\
471 To see all available top-level tasks you can start, run:\n \
472 ie task find --parent NULL --status todo",
473 message
474 )
475 },
476 _ => format!("[INFO] {}", message),
477 }
478 },
479 _ => "[ERROR] Unknown suggestion type".to_string(),
480 }
481 }
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct TaskBrief {
487 pub id: i64,
488 pub name: String,
489 pub status: String,
490 #[serde(skip_serializing_if = "Option::is_none")]
491 pub parent_id: Option<i64>,
492 #[serde(default)]
494 pub has_spec: bool,
495}
496
497impl From<&Task> for TaskBrief {
498 fn from(task: &Task) -> Self {
499 Self {
500 id: task.id,
501 name: task.name.clone(),
502 status: task.status.clone(),
503 parent_id: task.parent_id,
504 has_spec: task
505 .spec
506 .as_ref()
507 .map(|s| !s.trim().is_empty())
508 .unwrap_or(false),
509 }
510 }
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct StatusResponse {
516 pub focused_task: Task,
518 pub ancestors: Vec<Task>,
520 pub siblings: Vec<TaskBrief>,
522 pub descendants: Vec<TaskBrief>,
524 #[serde(skip_serializing_if = "Option::is_none")]
526 pub events: Option<Vec<Event>>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct NoFocusResponse {
532 pub message: String,
533 pub root_tasks: Vec<TaskBrief>,
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
539pub struct Suggestion {
540 pub id: i64,
541 #[serde(rename = "type")]
542 #[sqlx(rename = "type")]
543 pub suggestion_type: String,
544 pub content: String,
545 #[serde(with = "datetime_format")]
546 pub created_at: DateTime<Utc>,
547 pub dismissed: bool,
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 fn create_test_task(id: i64, name: &str, priority: Option<i32>) -> Task {
555 Task {
556 id,
557 parent_id: None,
558 name: name.to_string(),
559 spec: None,
560 status: "todo".to_string(),
561 complexity: None,
562 priority,
563 first_todo_at: None,
564 first_doing_at: None,
565 first_done_at: None,
566 active_form: None,
567 owner: "human".to_string(),
568 metadata: None,
569 }
570 }
571
572 #[test]
573 fn test_pick_next_response_focused_subtask() {
574 let task = create_test_task(1, "Test task", Some(5));
575 let response = PickNextResponse::focused_subtask(task.clone());
576
577 assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
578 assert!(response.task.is_some());
579 assert_eq!(response.task.unwrap().id, 1);
580 assert!(response.reason_code.is_none());
581 assert!(response.message.is_none());
582 }
583
584 #[test]
585 fn test_pick_next_response_top_level_task() {
586 let task = create_test_task(2, "Top level task", Some(3));
587 let response = PickNextResponse::top_level_task(task.clone());
588
589 assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
590 assert!(response.task.is_some());
591 assert_eq!(response.task.unwrap().id, 2);
592 assert!(response.reason_code.is_none());
593 assert!(response.message.is_none());
594 }
595
596 #[test]
597 fn test_pick_next_response_no_tasks_in_project() {
598 let response = PickNextResponse::no_tasks_in_project();
599
600 assert_eq!(response.suggestion_type, "NONE");
601 assert!(response.task.is_none());
602 assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
603 assert!(response.message.is_some());
604 assert!(response.message.unwrap().contains("No tasks found"));
605 }
606
607 #[test]
608 fn test_pick_next_response_all_tasks_completed() {
609 let response = PickNextResponse::all_tasks_completed();
610
611 assert_eq!(response.suggestion_type, "NONE");
612 assert!(response.task.is_none());
613 assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
614 assert!(response.message.is_some());
615 assert!(response.message.unwrap().contains("Project Complete"));
616 }
617
618 #[test]
619 fn test_pick_next_response_no_available_todos() {
620 let response = PickNextResponse::no_available_todos();
621
622 assert_eq!(response.suggestion_type, "NONE");
623 assert!(response.task.is_none());
624 assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
625 assert!(response.message.is_some());
626 }
627
628 #[test]
629 fn test_format_as_text_focused_subtask() {
630 let task = create_test_task(1, "Test task", Some(5));
631 let response = PickNextResponse::focused_subtask(task);
632 let text = response.format_as_text();
633
634 assert!(text.contains("Based on your current focus"));
635 assert!(text.contains("[ID: 1]"));
636 assert!(text.contains("[Priority: 5]"));
637 assert!(text.contains("Test task"));
638 assert!(text.contains("ie task start 1"));
639 }
640
641 #[test]
642 fn test_format_as_text_top_level_task() {
643 let task = create_test_task(2, "Top level task", None);
644 let response = PickNextResponse::top_level_task(task);
645 let text = response.format_as_text();
646
647 assert!(text.contains("Based on your current focus"));
648 assert!(text.contains("[ID: 2]"));
649 assert!(text.contains("[Priority: 0]")); assert!(text.contains("Top level task"));
651 assert!(text.contains("ie task start 2"));
652 }
653
654 #[test]
655 fn test_format_as_text_no_tasks_in_project() {
656 let response = PickNextResponse::no_tasks_in_project();
657 let text = response.format_as_text();
658
659 assert!(text.contains("[INFO]"));
660 assert!(text.contains("No tasks found"));
661 assert!(text.contains("ie task add"));
662 assert!(text.contains("--priority 1"));
663 }
664
665 #[test]
666 fn test_format_as_text_all_tasks_completed() {
667 let response = PickNextResponse::all_tasks_completed();
668 let text = response.format_as_text();
669
670 assert!(text.contains("[SUCCESS]"));
671 assert!(text.contains("Project Complete"));
672 assert!(text.contains("ie report --since 30d"));
673 }
674
675 #[test]
676 fn test_format_as_text_no_available_todos() {
677 let response = PickNextResponse::no_available_todos();
678 let text = response.format_as_text();
679
680 assert!(text.contains("[INFO]"));
681 assert!(text.contains("No immediate next task"));
682 assert!(text.contains("Possible reasons"));
683 assert!(text.contains("ie task find"));
684 }
685
686 #[test]
687 fn test_error_response_serialization() {
688 use crate::error::IntentError;
689
690 let error = IntentError::TaskNotFound(123);
691 let response = error.to_error_response();
692
693 assert_eq!(response.code, "TASK_NOT_FOUND");
694 assert!(response.error.contains("123"));
695 }
696
697 #[test]
698 fn test_next_step_suggestion_serialization() {
699 let suggestion = NextStepSuggestion::ParentIsReady {
700 message: "Test message".to_string(),
701 parent_task_id: 1,
702 parent_task_name: "Parent".to_string(),
703 };
704
705 let json = serde_json::to_string(&suggestion).unwrap();
706 assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
707 assert!(json.contains("parent_task_id"));
708 }
709
710 #[test]
711 fn test_task_with_events_serialization() {
712 let task = create_test_task(1, "Test", Some(5));
713 let task_with_events = TaskWithEvents {
714 task,
715 events_summary: None,
716 };
717
718 let json = serde_json::to_string(&task_with_events).unwrap();
719 assert!(json.contains("\"id\":1"));
720 assert!(json.contains("\"name\":\"Test\""));
721 assert!(!json.contains("events_summary"));
723 }
724
725 #[test]
726 fn test_report_summary_with_date_range() {
727 let from = Utc::now() - chrono::Duration::days(7);
728 let to = Utc::now();
729
730 let summary = ReportSummary {
731 total_tasks: 10,
732 tasks_by_status: StatusBreakdown {
733 todo: 5,
734 doing: 3,
735 done: 2,
736 },
737 total_events: 20,
738 date_range: Some(DateRange { from, to }),
739 };
740
741 let json = serde_json::to_string(&summary).unwrap();
742 assert!(json.contains("\"total_tasks\":10"));
743 assert!(json.contains("\"total_events\":20"));
744 assert!(json.contains("date_range"));
745 }
746}