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 UnifiedSearchResult {
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 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 active_form: None,
349 }
350 }
351
352 #[test]
353 fn test_pick_next_response_focused_subtask() {
354 let task = create_test_task(1, "Test task", Some(5));
355 let response = PickNextResponse::focused_subtask(task.clone());
356
357 assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
358 assert!(response.task.is_some());
359 assert_eq!(response.task.unwrap().id, 1);
360 assert!(response.reason_code.is_none());
361 assert!(response.message.is_none());
362 }
363
364 #[test]
365 fn test_pick_next_response_top_level_task() {
366 let task = create_test_task(2, "Top level task", Some(3));
367 let response = PickNextResponse::top_level_task(task.clone());
368
369 assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
370 assert!(response.task.is_some());
371 assert_eq!(response.task.unwrap().id, 2);
372 assert!(response.reason_code.is_none());
373 assert!(response.message.is_none());
374 }
375
376 #[test]
377 fn test_pick_next_response_no_tasks_in_project() {
378 let response = PickNextResponse::no_tasks_in_project();
379
380 assert_eq!(response.suggestion_type, "NONE");
381 assert!(response.task.is_none());
382 assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
383 assert!(response.message.is_some());
384 assert!(response.message.unwrap().contains("No tasks found"));
385 }
386
387 #[test]
388 fn test_pick_next_response_all_tasks_completed() {
389 let response = PickNextResponse::all_tasks_completed();
390
391 assert_eq!(response.suggestion_type, "NONE");
392 assert!(response.task.is_none());
393 assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
394 assert!(response.message.is_some());
395 assert!(response.message.unwrap().contains("Project Complete"));
396 }
397
398 #[test]
399 fn test_pick_next_response_no_available_todos() {
400 let response = PickNextResponse::no_available_todos();
401
402 assert_eq!(response.suggestion_type, "NONE");
403 assert!(response.task.is_none());
404 assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
405 assert!(response.message.is_some());
406 }
407
408 #[test]
409 fn test_format_as_text_focused_subtask() {
410 let task = create_test_task(1, "Test task", Some(5));
411 let response = PickNextResponse::focused_subtask(task);
412 let text = response.format_as_text();
413
414 assert!(text.contains("Based on your current focus"));
415 assert!(text.contains("[ID: 1]"));
416 assert!(text.contains("[Priority: 5]"));
417 assert!(text.contains("Test task"));
418 assert!(text.contains("ie task start 1"));
419 }
420
421 #[test]
422 fn test_format_as_text_top_level_task() {
423 let task = create_test_task(2, "Top level task", None);
424 let response = PickNextResponse::top_level_task(task);
425 let text = response.format_as_text();
426
427 assert!(text.contains("Based on your current focus"));
428 assert!(text.contains("[ID: 2]"));
429 assert!(text.contains("[Priority: 0]")); assert!(text.contains("Top level task"));
431 assert!(text.contains("ie task start 2"));
432 }
433
434 #[test]
435 fn test_format_as_text_no_tasks_in_project() {
436 let response = PickNextResponse::no_tasks_in_project();
437 let text = response.format_as_text();
438
439 assert!(text.contains("[INFO]"));
440 assert!(text.contains("No tasks found"));
441 assert!(text.contains("ie task add"));
442 assert!(text.contains("--priority 1"));
443 }
444
445 #[test]
446 fn test_format_as_text_all_tasks_completed() {
447 let response = PickNextResponse::all_tasks_completed();
448 let text = response.format_as_text();
449
450 assert!(text.contains("[SUCCESS]"));
451 assert!(text.contains("Project Complete"));
452 assert!(text.contains("ie report --since 30d"));
453 }
454
455 #[test]
456 fn test_format_as_text_no_available_todos() {
457 let response = PickNextResponse::no_available_todos();
458 let text = response.format_as_text();
459
460 assert!(text.contains("[INFO]"));
461 assert!(text.contains("No immediate next task"));
462 assert!(text.contains("Possible reasons"));
463 assert!(text.contains("ie task find"));
464 }
465
466 #[test]
467 fn test_error_response_serialization() {
468 use crate::error::IntentError;
469
470 let error = IntentError::TaskNotFound(123);
471 let response = error.to_error_response();
472
473 assert_eq!(response.code, "TASK_NOT_FOUND");
474 assert!(response.error.contains("123"));
475 }
476
477 #[test]
478 fn test_next_step_suggestion_serialization() {
479 let suggestion = NextStepSuggestion::ParentIsReady {
480 message: "Test message".to_string(),
481 parent_task_id: 1,
482 parent_task_name: "Parent".to_string(),
483 };
484
485 let json = serde_json::to_string(&suggestion).unwrap();
486 assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
487 assert!(json.contains("parent_task_id"));
488 }
489
490 #[test]
491 fn test_task_with_events_serialization() {
492 let task = create_test_task(1, "Test", Some(5));
493 let task_with_events = TaskWithEvents {
494 task,
495 events_summary: None,
496 };
497
498 let json = serde_json::to_string(&task_with_events).unwrap();
499 assert!(json.contains("\"id\":1"));
500 assert!(json.contains("\"name\":\"Test\""));
501 assert!(!json.contains("events_summary"));
503 }
504
505 #[test]
506 fn test_report_summary_with_date_range() {
507 let from = Utc::now() - chrono::Duration::days(7);
508 let to = Utc::now();
509
510 let summary = ReportSummary {
511 total_tasks: 10,
512 tasks_by_status: StatusBreakdown {
513 todo: 5,
514 doing: 3,
515 done: 2,
516 },
517 total_events: 20,
518 date_range: Some(DateRange { from, to }),
519 };
520
521 let json = serde_json::to_string(&summary).unwrap();
522 assert!(json.contains("\"total_tasks\":10"));
523 assert!(json.contains("\"total_events\":20"));
524 assert!(json.contains("date_range"));
525 }
526}