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