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}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TaskWithEvents {
32 #[serde(flatten)]
33 pub task: Task,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub events_summary: Option<EventsSummary>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct EventsSummary {
40 pub total_count: i64,
41 pub recent_events: Vec<Event>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
45pub struct Event {
46 pub id: i64,
47 pub task_id: i64,
48 pub timestamp: DateTime<Utc>,
49 pub log_type: String,
50 pub discussion_data: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
54pub struct WorkspaceState {
55 pub key: String,
56 pub value: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Report {
61 pub summary: ReportSummary,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub tasks: Option<Vec<Task>>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub events: Option<Vec<Event>>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ReportSummary {
70 pub total_tasks: i64,
71 pub tasks_by_status: StatusBreakdown,
72 pub total_events: i64,
73 pub date_range: Option<DateRange>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct StatusBreakdown {
78 pub todo: i64,
79 pub doing: i64,
80 pub done: i64,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct DateRange {
85 pub from: DateTime<Utc>,
86 pub to: DateTime<Utc>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct DoneTaskResponse {
91 pub completed_task: Task,
92 pub workspace_status: WorkspaceStatus,
93 pub next_step_suggestion: NextStepSuggestion,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct WorkspaceStatus {
98 pub current_task_id: Option<i64>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[serde(tag = "type")]
103pub enum NextStepSuggestion {
104 #[serde(rename = "PARENT_IS_READY")]
105 ParentIsReady {
106 message: String,
107 parent_task_id: i64,
108 parent_task_name: String,
109 },
110 #[serde(rename = "SIBLING_TASKS_REMAIN")]
111 SiblingTasksRemain {
112 message: String,
113 parent_task_id: i64,
114 parent_task_name: String,
115 remaining_siblings_count: i64,
116 },
117 #[serde(rename = "TOP_LEVEL_TASK_COMPLETED")]
118 TopLevelTaskCompleted {
119 message: String,
120 completed_task_id: i64,
121 completed_task_name: String,
122 },
123 #[serde(rename = "NO_PARENT_CONTEXT")]
124 NoParentContext {
125 message: String,
126 completed_task_id: i64,
127 completed_task_name: String,
128 },
129 #[serde(rename = "WORKSPACE_IS_CLEAR")]
130 WorkspaceIsClear {
131 message: String,
132 completed_task_id: i64,
133 },
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct TaskSearchResult {
138 #[serde(flatten)]
139 pub task: Task,
140 pub match_snippet: String,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(tag = "result_type")]
146pub enum UnifiedSearchResult {
147 #[serde(rename = "task")]
148 Task {
149 #[serde(flatten)]
150 task: Task,
151 match_snippet: String,
152 match_field: String, },
154 #[serde(rename = "event")]
155 Event {
156 event: Event,
157 task_chain: Vec<Task>, match_snippet: String,
159 },
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct SwitchTaskResponse {
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub previous_task: Option<PreviousTaskInfo>,
167 pub current_task: CurrentTaskInfo,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct PreviousTaskInfo {
173 pub id: i64,
174 pub status: String,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct CurrentTaskInfo {
180 pub id: i64,
181 pub name: String,
182 pub status: String,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SpawnSubtaskResponse {
188 pub subtask: SubtaskInfo,
189 pub parent_task: ParentTaskInfo,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct SubtaskInfo {
195 pub id: i64,
196 pub name: String,
197 pub parent_id: i64,
198 pub status: String,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ParentTaskInfo {
204 pub id: i64,
205 pub name: String,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct TaskDependencies {
211 pub blocking_tasks: Vec<Task>,
213 pub blocked_by_tasks: Vec<Task>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct TaskContext {
220 pub task: Task,
221 pub ancestors: Vec<Task>,
222 pub siblings: Vec<Task>,
223 pub children: Vec<Task>,
224 pub dependencies: TaskDependencies,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct PickNextResponse {
229 pub suggestion_type: String,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub task: Option<Task>,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub reason_code: Option<String>,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub message: Option<String>,
236}
237
238impl PickNextResponse {
239 pub fn focused_subtask(task: Task) -> Self {
241 Self {
242 suggestion_type: "FOCUSED_SUB_TASK".to_string(),
243 task: Some(task),
244 reason_code: None,
245 message: None,
246 }
247 }
248
249 pub fn top_level_task(task: Task) -> Self {
251 Self {
252 suggestion_type: "TOP_LEVEL_TASK".to_string(),
253 task: Some(task),
254 reason_code: None,
255 message: None,
256 }
257 }
258
259 pub fn no_tasks_in_project() -> Self {
261 Self {
262 suggestion_type: "NONE".to_string(),
263 task: None,
264 reason_code: Some("NO_TASKS_IN_PROJECT".to_string()),
265 message: Some(
266 "No tasks found in this project. Your intent backlog is empty.".to_string(),
267 ),
268 }
269 }
270
271 pub fn all_tasks_completed() -> Self {
273 Self {
274 suggestion_type: "NONE".to_string(),
275 task: None,
276 reason_code: Some("ALL_TASKS_COMPLETED".to_string()),
277 message: Some("Project Complete! All intents have been realized.".to_string()),
278 }
279 }
280
281 pub fn no_available_todos() -> Self {
283 Self {
284 suggestion_type: "NONE".to_string(),
285 task: None,
286 reason_code: Some("NO_AVAILABLE_TODOS".to_string()),
287 message: Some("No immediate next task found based on the current context.".to_string()),
288 }
289 }
290
291 pub fn format_as_text(&self) -> String {
293 match self.suggestion_type.as_str() {
294 "FOCUSED_SUB_TASK" | "TOP_LEVEL_TASK" => {
295 if let Some(task) = &self.task {
296 format!(
297 "Based on your current focus, the recommended next task is:\n\n\
298 [ID: {}] [Priority: {}] [Status: {}]\n\
299 Name: {}\n\n\
300 To start working on it, run:\n ie task start {}",
301 task.id,
302 task.priority.unwrap_or(0),
303 task.status,
304 task.name,
305 task.id
306 )
307 } else {
308 "[ERROR] Invalid response: task is missing".to_string()
309 }
310 },
311 "NONE" => {
312 let reason_code = self.reason_code.as_deref().unwrap_or("UNKNOWN");
313 let message = self.message.as_deref().unwrap_or("No tasks found");
314
315 match reason_code {
316 "NO_TASKS_IN_PROJECT" => {
317 format!(
318 "[INFO] {}\n\n\
319 To get started, capture your first high-level intent:\n \
320 ie task add --name \"Setup initial project structure\" --priority 1",
321 message
322 )
323 },
324 "ALL_TASKS_COMPLETED" => {
325 format!(
326 "[SUCCESS] {}\n\n\
327 You can review the accomplishments of the last 30 days with:\n \
328 ie report --since 30d",
329 message
330 )
331 },
332 "NO_AVAILABLE_TODOS" => {
333 format!(
334 "[INFO] {}\n\n\
335 Possible reasons:\n\
336 - All available 'todo' tasks are part of larger epics that have not been started yet.\n\
337 - You are not currently focused on a task that has 'todo' sub-tasks.\n\n\
338 To see all available top-level tasks you can start, run:\n \
339 ie task find --parent NULL --status todo",
340 message
341 )
342 },
343 _ => format!("[INFO] {}", message),
344 }
345 },
346 _ => "[ERROR] Unknown suggestion type".to_string(),
347 }
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 fn create_test_task(id: i64, name: &str, priority: Option<i32>) -> Task {
356 Task {
357 id,
358 parent_id: None,
359 name: name.to_string(),
360 spec: None,
361 status: "todo".to_string(),
362 complexity: None,
363 priority,
364 first_todo_at: None,
365 first_doing_at: None,
366 first_done_at: None,
367 }
368 }
369
370 #[test]
371 fn test_pick_next_response_focused_subtask() {
372 let task = create_test_task(1, "Test task", Some(5));
373 let response = PickNextResponse::focused_subtask(task.clone());
374
375 assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
376 assert!(response.task.is_some());
377 assert_eq!(response.task.unwrap().id, 1);
378 assert!(response.reason_code.is_none());
379 assert!(response.message.is_none());
380 }
381
382 #[test]
383 fn test_pick_next_response_top_level_task() {
384 let task = create_test_task(2, "Top level task", Some(3));
385 let response = PickNextResponse::top_level_task(task.clone());
386
387 assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
388 assert!(response.task.is_some());
389 assert_eq!(response.task.unwrap().id, 2);
390 assert!(response.reason_code.is_none());
391 assert!(response.message.is_none());
392 }
393
394 #[test]
395 fn test_pick_next_response_no_tasks_in_project() {
396 let response = PickNextResponse::no_tasks_in_project();
397
398 assert_eq!(response.suggestion_type, "NONE");
399 assert!(response.task.is_none());
400 assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
401 assert!(response.message.is_some());
402 assert!(response.message.unwrap().contains("No tasks found"));
403 }
404
405 #[test]
406 fn test_pick_next_response_all_tasks_completed() {
407 let response = PickNextResponse::all_tasks_completed();
408
409 assert_eq!(response.suggestion_type, "NONE");
410 assert!(response.task.is_none());
411 assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
412 assert!(response.message.is_some());
413 assert!(response.message.unwrap().contains("Project Complete"));
414 }
415
416 #[test]
417 fn test_pick_next_response_no_available_todos() {
418 let response = PickNextResponse::no_available_todos();
419
420 assert_eq!(response.suggestion_type, "NONE");
421 assert!(response.task.is_none());
422 assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
423 assert!(response.message.is_some());
424 }
425
426 #[test]
427 fn test_format_as_text_focused_subtask() {
428 let task = create_test_task(1, "Test task", Some(5));
429 let response = PickNextResponse::focused_subtask(task);
430 let text = response.format_as_text();
431
432 assert!(text.contains("Based on your current focus"));
433 assert!(text.contains("[ID: 1]"));
434 assert!(text.contains("[Priority: 5]"));
435 assert!(text.contains("Test task"));
436 assert!(text.contains("ie task start 1"));
437 }
438
439 #[test]
440 fn test_format_as_text_top_level_task() {
441 let task = create_test_task(2, "Top level task", None);
442 let response = PickNextResponse::top_level_task(task);
443 let text = response.format_as_text();
444
445 assert!(text.contains("Based on your current focus"));
446 assert!(text.contains("[ID: 2]"));
447 assert!(text.contains("[Priority: 0]")); assert!(text.contains("Top level task"));
449 assert!(text.contains("ie task start 2"));
450 }
451
452 #[test]
453 fn test_format_as_text_no_tasks_in_project() {
454 let response = PickNextResponse::no_tasks_in_project();
455 let text = response.format_as_text();
456
457 assert!(text.contains("[INFO]"));
458 assert!(text.contains("No tasks found"));
459 assert!(text.contains("ie task add"));
460 assert!(text.contains("--priority 1"));
461 }
462
463 #[test]
464 fn test_format_as_text_all_tasks_completed() {
465 let response = PickNextResponse::all_tasks_completed();
466 let text = response.format_as_text();
467
468 assert!(text.contains("[SUCCESS]"));
469 assert!(text.contains("Project Complete"));
470 assert!(text.contains("ie report --since 30d"));
471 }
472
473 #[test]
474 fn test_format_as_text_no_available_todos() {
475 let response = PickNextResponse::no_available_todos();
476 let text = response.format_as_text();
477
478 assert!(text.contains("[INFO]"));
479 assert!(text.contains("No immediate next task"));
480 assert!(text.contains("Possible reasons"));
481 assert!(text.contains("ie task find"));
482 }
483
484 #[test]
485 fn test_error_response_serialization() {
486 use crate::error::IntentError;
487
488 let error = IntentError::TaskNotFound(123);
489 let response = error.to_error_response();
490
491 assert_eq!(response.code, "TASK_NOT_FOUND");
492 assert!(response.error.contains("123"));
493 }
494
495 #[test]
496 fn test_next_step_suggestion_serialization() {
497 let suggestion = NextStepSuggestion::ParentIsReady {
498 message: "Test message".to_string(),
499 parent_task_id: 1,
500 parent_task_name: "Parent".to_string(),
501 };
502
503 let json = serde_json::to_string(&suggestion).unwrap();
504 assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
505 assert!(json.contains("parent_task_id"));
506 }
507
508 #[test]
509 fn test_task_with_events_serialization() {
510 let task = create_test_task(1, "Test", Some(5));
511 let task_with_events = TaskWithEvents {
512 task,
513 events_summary: None,
514 };
515
516 let json = serde_json::to_string(&task_with_events).unwrap();
517 assert!(json.contains("\"id\":1"));
518 assert!(json.contains("\"name\":\"Test\""));
519 assert!(!json.contains("events_summary"));
521 }
522
523 #[test]
524 fn test_report_summary_with_date_range() {
525 let from = Utc::now() - chrono::Duration::days(7);
526 let to = Utc::now();
527
528 let summary = ReportSummary {
529 total_tasks: 10,
530 tasks_by_status: StatusBreakdown {
531 todo: 5,
532 doing: 3,
533 done: 2,
534 },
535 total_events: 20,
536 date_range: Some(DateRange { from, to }),
537 };
538
539 let json = serde_json::to_string(&summary).unwrap();
540 assert!(json.contains("\"total_tasks\":10"));
541 assert!(json.contains("\"total_events\":20"));
542 assert!(json.contains("date_range"));
543 }
544}