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 #[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#[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, },
158 #[serde(rename = "event")]
159 Event {
160 event: Event,
161 task_chain: Vec<Task>, match_snippet: String,
163 },
164}
165
166#[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#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct SpawnSubtaskResponse {
180 pub subtask: SubtaskInfo,
181 pub parent_task: ParentTaskInfo,
182}
183
184#[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#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ParentTaskInfo {
196 pub id: i64,
197 pub name: String,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct TaskDependencies {
203 pub blocking_tasks: Vec<Task>,
205 pub blocked_by_tasks: Vec<Task>,
207}
208
209#[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#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum TaskSortBy {
223 Id,
225 Priority,
227 Time,
229 #[default]
231 FocusAware,
232}
233
234#[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#[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 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 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 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 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 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 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]")); 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 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}