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