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)]
145pub struct SwitchTaskResponse {
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub previous_task: Option<PreviousTaskInfo>,
148 pub current_task: CurrentTaskInfo,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct PreviousTaskInfo {
154 pub id: i64,
155 pub status: String,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct CurrentTaskInfo {
161 pub id: i64,
162 pub name: String,
163 pub status: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SpawnSubtaskResponse {
169 pub subtask: SubtaskInfo,
170 pub parent_task: ParentTaskInfo,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SubtaskInfo {
176 pub id: i64,
177 pub name: String,
178 pub parent_id: i64,
179 pub status: String,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ParentTaskInfo {
185 pub id: i64,
186 pub name: String,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct TaskDependencies {
192 pub blocking_tasks: Vec<Task>,
194 pub blocked_by_tasks: Vec<Task>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct TaskContext {
201 pub task: Task,
202 pub ancestors: Vec<Task>,
203 pub siblings: Vec<Task>,
204 pub children: Vec<Task>,
205 pub dependencies: TaskDependencies,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct PickNextResponse {
210 pub suggestion_type: String,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 pub task: Option<Task>,
213 #[serde(skip_serializing_if = "Option::is_none")]
214 pub reason_code: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none")]
216 pub message: Option<String>,
217}
218
219impl PickNextResponse {
220 pub fn focused_subtask(task: Task) -> Self {
222 Self {
223 suggestion_type: "FOCUSED_SUB_TASK".to_string(),
224 task: Some(task),
225 reason_code: None,
226 message: None,
227 }
228 }
229
230 pub fn top_level_task(task: Task) -> Self {
232 Self {
233 suggestion_type: "TOP_LEVEL_TASK".to_string(),
234 task: Some(task),
235 reason_code: None,
236 message: None,
237 }
238 }
239
240 pub fn no_tasks_in_project() -> Self {
242 Self {
243 suggestion_type: "NONE".to_string(),
244 task: None,
245 reason_code: Some("NO_TASKS_IN_PROJECT".to_string()),
246 message: Some(
247 "No tasks found in this project. Your intent backlog is empty.".to_string(),
248 ),
249 }
250 }
251
252 pub fn all_tasks_completed() -> Self {
254 Self {
255 suggestion_type: "NONE".to_string(),
256 task: None,
257 reason_code: Some("ALL_TASKS_COMPLETED".to_string()),
258 message: Some("Project Complete! All intents have been realized.".to_string()),
259 }
260 }
261
262 pub fn no_available_todos() -> Self {
264 Self {
265 suggestion_type: "NONE".to_string(),
266 task: None,
267 reason_code: Some("NO_AVAILABLE_TODOS".to_string()),
268 message: Some("No immediate next task found based on the current context.".to_string()),
269 }
270 }
271
272 pub fn format_as_text(&self) -> String {
274 match self.suggestion_type.as_str() {
275 "FOCUSED_SUB_TASK" | "TOP_LEVEL_TASK" => {
276 if let Some(task) = &self.task {
277 format!(
278 "Based on your current focus, the recommended next task is:\n\n\
279 [ID: {}] [Priority: {}] [Status: {}]\n\
280 Name: {}\n\n\
281 To start working on it, run:\n ie task start {}",
282 task.id,
283 task.priority.unwrap_or(0),
284 task.status,
285 task.name,
286 task.id
287 )
288 } else {
289 "[ERROR] Invalid response: task is missing".to_string()
290 }
291 },
292 "NONE" => {
293 let reason_code = self.reason_code.as_deref().unwrap_or("UNKNOWN");
294 let message = self.message.as_deref().unwrap_or("No tasks found");
295
296 match reason_code {
297 "NO_TASKS_IN_PROJECT" => {
298 format!(
299 "[INFO] {}\n\n\
300 To get started, capture your first high-level intent:\n \
301 ie task add --name \"Setup initial project structure\" --priority 1",
302 message
303 )
304 },
305 "ALL_TASKS_COMPLETED" => {
306 format!(
307 "[SUCCESS] {}\n\n\
308 You can review the accomplishments of the last 30 days with:\n \
309 ie report --since 30d",
310 message
311 )
312 },
313 "NO_AVAILABLE_TODOS" => {
314 format!(
315 "[INFO] {}\n\n\
316 Possible reasons:\n\
317 - All available 'todo' tasks are part of larger epics that have not been started yet.\n\
318 - You are not currently focused on a task that has 'todo' sub-tasks.\n\n\
319 To see all available top-level tasks you can start, run:\n \
320 ie task find --parent NULL --status todo",
321 message
322 )
323 },
324 _ => format!("[INFO] {}", message),
325 }
326 },
327 _ => "[ERROR] Unknown suggestion type".to_string(),
328 }
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 fn create_test_task(id: i64, name: &str, priority: Option<i32>) -> Task {
337 Task {
338 id,
339 parent_id: None,
340 name: name.to_string(),
341 spec: None,
342 status: "todo".to_string(),
343 complexity: None,
344 priority,
345 first_todo_at: None,
346 first_doing_at: None,
347 first_done_at: None,
348 }
349 }
350
351 #[test]
352 fn test_pick_next_response_focused_subtask() {
353 let task = create_test_task(1, "Test task", Some(5));
354 let response = PickNextResponse::focused_subtask(task.clone());
355
356 assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
357 assert!(response.task.is_some());
358 assert_eq!(response.task.unwrap().id, 1);
359 assert!(response.reason_code.is_none());
360 assert!(response.message.is_none());
361 }
362
363 #[test]
364 fn test_pick_next_response_top_level_task() {
365 let task = create_test_task(2, "Top level task", Some(3));
366 let response = PickNextResponse::top_level_task(task.clone());
367
368 assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
369 assert!(response.task.is_some());
370 assert_eq!(response.task.unwrap().id, 2);
371 assert!(response.reason_code.is_none());
372 assert!(response.message.is_none());
373 }
374
375 #[test]
376 fn test_pick_next_response_no_tasks_in_project() {
377 let response = PickNextResponse::no_tasks_in_project();
378
379 assert_eq!(response.suggestion_type, "NONE");
380 assert!(response.task.is_none());
381 assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
382 assert!(response.message.is_some());
383 assert!(response.message.unwrap().contains("No tasks found"));
384 }
385
386 #[test]
387 fn test_pick_next_response_all_tasks_completed() {
388 let response = PickNextResponse::all_tasks_completed();
389
390 assert_eq!(response.suggestion_type, "NONE");
391 assert!(response.task.is_none());
392 assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
393 assert!(response.message.is_some());
394 assert!(response.message.unwrap().contains("Project Complete"));
395 }
396
397 #[test]
398 fn test_pick_next_response_no_available_todos() {
399 let response = PickNextResponse::no_available_todos();
400
401 assert_eq!(response.suggestion_type, "NONE");
402 assert!(response.task.is_none());
403 assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
404 assert!(response.message.is_some());
405 }
406
407 #[test]
408 fn test_format_as_text_focused_subtask() {
409 let task = create_test_task(1, "Test task", Some(5));
410 let response = PickNextResponse::focused_subtask(task);
411 let text = response.format_as_text();
412
413 assert!(text.contains("Based on your current focus"));
414 assert!(text.contains("[ID: 1]"));
415 assert!(text.contains("[Priority: 5]"));
416 assert!(text.contains("Test task"));
417 assert!(text.contains("ie task start 1"));
418 }
419
420 #[test]
421 fn test_format_as_text_top_level_task() {
422 let task = create_test_task(2, "Top level task", None);
423 let response = PickNextResponse::top_level_task(task);
424 let text = response.format_as_text();
425
426 assert!(text.contains("Based on your current focus"));
427 assert!(text.contains("[ID: 2]"));
428 assert!(text.contains("[Priority: 0]")); assert!(text.contains("Top level task"));
430 assert!(text.contains("ie task start 2"));
431 }
432
433 #[test]
434 fn test_format_as_text_no_tasks_in_project() {
435 let response = PickNextResponse::no_tasks_in_project();
436 let text = response.format_as_text();
437
438 assert!(text.contains("[INFO]"));
439 assert!(text.contains("No tasks found"));
440 assert!(text.contains("ie task add"));
441 assert!(text.contains("--priority 1"));
442 }
443
444 #[test]
445 fn test_format_as_text_all_tasks_completed() {
446 let response = PickNextResponse::all_tasks_completed();
447 let text = response.format_as_text();
448
449 assert!(text.contains("[SUCCESS]"));
450 assert!(text.contains("Project Complete"));
451 assert!(text.contains("ie report --since 30d"));
452 }
453
454 #[test]
455 fn test_format_as_text_no_available_todos() {
456 let response = PickNextResponse::no_available_todos();
457 let text = response.format_as_text();
458
459 assert!(text.contains("[INFO]"));
460 assert!(text.contains("No immediate next task"));
461 assert!(text.contains("Possible reasons"));
462 assert!(text.contains("ie task find"));
463 }
464
465 #[test]
466 fn test_error_response_serialization() {
467 use crate::error::IntentError;
468
469 let error = IntentError::TaskNotFound(123);
470 let response = error.to_error_response();
471
472 assert_eq!(response.code, "TASK_NOT_FOUND");
473 assert!(response.error.contains("123"));
474 }
475
476 #[test]
477 fn test_next_step_suggestion_serialization() {
478 let suggestion = NextStepSuggestion::ParentIsReady {
479 message: "Test message".to_string(),
480 parent_task_id: 1,
481 parent_task_name: "Parent".to_string(),
482 };
483
484 let json = serde_json::to_string(&suggestion).unwrap();
485 assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
486 assert!(json.contains("parent_task_id"));
487 }
488
489 #[test]
490 fn test_task_with_events_serialization() {
491 let task = create_test_task(1, "Test", Some(5));
492 let task_with_events = TaskWithEvents {
493 task,
494 events_summary: None,
495 };
496
497 let json = serde_json::to_string(&task_with_events).unwrap();
498 assert!(json.contains("\"id\":1"));
499 assert!(json.contains("\"name\":\"Test\""));
500 assert!(!json.contains("events_summary"));
502 }
503
504 #[test]
505 fn test_report_summary_with_date_range() {
506 let from = Utc::now() - chrono::Duration::days(7);
507 let to = Utc::now();
508
509 let summary = ReportSummary {
510 total_tasks: 10,
511 tasks_by_status: StatusBreakdown {
512 todo: 5,
513 doing: 3,
514 done: 2,
515 },
516 total_events: 20,
517 date_range: Some(DateRange { from, to }),
518 };
519
520 let json = serde_json::to_string(&summary).unwrap();
521 assert!(json.contains("\"total_tasks\":10"));
522 assert!(json.contains("\"total_events\":20"));
523 assert!(json.contains("date_range"));
524 }
525}