1use crate::config::StatesConfig;
4use crate::types::{PRIORITY_DEFAULT, ScanResult, Task, TaskTree, WorkerInfo};
5use serde_json::Value;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum OutputFormat {
12 #[default]
13 Json,
14 Markdown,
15}
16
17impl OutputFormat {
18 pub fn parse(s: &str) -> Option<Self> {
19 match s.to_lowercase().as_str() {
20 "json" => Some(OutputFormat::Json),
21 "markdown" | "md" => Some(OutputFormat::Markdown),
22 _ => None,
23 }
24 }
25}
26
27pub fn format_task_markdown(task: &Task, blocked_by: &[String]) -> String {
29 let mut md = String::new();
30
31 md.push_str(&format!("## Task: {}\n", task.title));
32 md.push_str(&format!("- **id**: `{}`\n", task.id));
33 md.push_str(&format!("- **status**: {}\n", task.status));
34 md.push_str(&format!("- **priority**: {}\n", task.priority));
35
36 if let Some(ref owner) = task.worker_id {
37 md.push_str(&format!("- **owner**: {}\n", owner));
38 }
39
40 if !blocked_by.is_empty() {
41 let blockers: Vec<String> = blocked_by.iter().map(|id| format!("`{}`", id)).collect();
42 md.push_str(&format!("- **blocked_by**: {}\n", blockers.join(", ")));
43 }
44
45 if let Some(points) = task.points {
46 md.push_str(&format!("- **points**: {}\n", points));
47 }
48
49 if let Some(ref thought) = task.current_thought {
50 md.push_str(&format!("- **thought**: {}\n", thought));
51 }
52
53 if let Some(ref desc) = task.description {
54 md.push_str("\n### Description\n");
55 md.push_str(desc);
56 md.push('\n');
57 }
58
59 md
60}
61
62pub fn format_tasks_markdown(
65 tasks: &[(Task, Vec<String>)],
66 states_config: &StatesConfig,
67) -> String {
68 let mut md = String::new();
69
70 md.push_str(&format!("# Tasks ({})\n\n", tasks.len()));
71
72 let mut by_status: HashMap<String, Vec<&(Task, Vec<String>)>> = HashMap::new();
74 for state in states_config.state_names() {
75 by_status.insert(state.to_string(), Vec::new());
76 }
77 for task_entry in tasks {
78 by_status
79 .entry(task_entry.0.status.clone())
80 .or_default()
81 .push(task_entry);
82 }
83
84 for state in &states_config.blocking_states {
89 if state != &states_config.initial
90 && let Some(state_tasks) = by_status.get(state)
91 && !state_tasks.is_empty() {
92 md.push_str(&format!("## {}\n\n", format_state_name(state)));
93 for (task, blocked_by) in state_tasks {
94 md.push_str(&format_task_short(task, blocked_by));
95 }
96 md.push('\n');
97 }
98 }
99
100 if let Some(state_tasks) = by_status.get(&states_config.initial)
102 && !state_tasks.is_empty() {
103 md.push_str(&format!(
104 "## {}\n\n",
105 format_state_name(&states_config.initial)
106 ));
107 for (task, blocked_by) in state_tasks {
108 md.push_str(&format_task_short(task, blocked_by));
109 }
110 md.push('\n');
111 }
112
113 for state in states_config.state_names() {
115 if !states_config.is_blocking_state(state) && state != states_config.initial
116 && let Some(state_tasks) = by_status.get(state)
117 && !state_tasks.is_empty() {
118 md.push_str(&format!("## {}\n\n", format_state_name(state)));
119 for (task, blocked_by) in state_tasks {
120 md.push_str(&format_task_short(task, blocked_by));
121 }
122 md.push('\n');
123 }
124 }
125
126 md
127}
128
129fn format_state_name(state: &str) -> String {
131 state
132 .split('_')
133 .map(|word| {
134 let mut chars = word.chars();
135 match chars.next() {
136 None => String::new(),
137 Some(first) => first.to_uppercase().chain(chars).collect(),
138 }
139 })
140 .collect::<Vec<_>>()
141 .join(" ")
142}
143
144fn format_task_short(task: &Task, blocked_by: &[String]) -> String {
146 let priority_marker = if task.priority > 0 { "!!! " } else { "" };
147
148 let blocked = if blocked_by.is_empty() {
149 String::new()
150 } else {
151 format!(" [blocked by {}]", blocked_by.len())
152 };
153
154 let owner = task
155 .worker_id
156 .as_ref()
157 .map(|o| format!(" @{}", o))
158 .unwrap_or_default();
159
160 let thought = task
161 .current_thought
162 .as_ref()
163 .map(|t| format!(" - _{}_", t))
164 .unwrap_or_default();
165
166 format!(
167 "- {}{} `{}`{}{}{}\n",
168 priority_marker,
169 task.title,
170 &task.id[..8.min(task.id.len())],
171 owner,
172 blocked,
173 thought,
174 )
175}
176
177pub fn format_workers_markdown(workers: &[WorkerInfo]) -> String {
179 let mut md = String::new();
180
181 md.push_str(&format!("# Workers ({})\n\n", workers.len()));
182
183 for worker in workers {
184 md.push_str(&format!("## {}\n", worker.id));
185 md.push_str(&format!("- **id**: `{}`\n", worker.id));
186
187 if !worker.tags.is_empty() {
188 md.push_str(&format!("- **tags**: {}\n", worker.tags.join(", ")));
189 }
190
191 md.push_str(&format!(
192 "- **claims**: {}/{}\n",
193 worker.claim_count, worker.max_claims
194 ));
195
196 if let Some(ref thought) = worker.current_thought {
197 md.push_str(&format!("- **doing**: {}\n", thought));
198 }
199
200 md.push('\n');
201 }
202
203 md
204}
205
206pub fn format_attachments_markdown(attachments: &[crate::types::AttachmentMeta]) -> String {
208 let mut md = String::new();
209
210 md.push_str(&format!("# Attachments ({})\n\n", attachments.len()));
211
212 if attachments.is_empty() {
213 md.push_str("_No attachments found._\n");
214 return md;
215 }
216
217 for attachment in attachments {
218 md.push_str(&format!("## {}\n", attachment.name));
219 md.push_str(&format!("- **index**: {}\n", attachment.order_index));
220 md.push_str(&format!("- **mime**: {}\n", attachment.mime_type));
221
222 if let Some(ref fp) = attachment.file_path {
223 md.push_str(&format!("- **file**: `{}`\n", fp));
224 }
225
226 let created_secs = attachment.created_at / 1000;
228 md.push_str(&format!("- **created**: {}\n", created_secs));
229
230 md.push('\n');
231 }
232
233 md
234}
235
236pub fn markdown_to_json(md: String) -> Value {
238 serde_json::json!({
239 "format": "markdown",
240 "content": md
241 })
242}
243
244#[derive(Debug)]
246pub enum ToolResult {
247 Json(Value),
249 Raw(String),
251}
252
253impl ToolResult {
254 pub fn json(value: Value) -> Self {
256 ToolResult::Json(value)
257 }
258
259 pub fn raw(text: String) -> Self {
261 ToolResult::Raw(text)
262 }
263
264 pub fn into_string(self) -> String {
266 match self {
267 ToolResult::Json(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
268 ToolResult::Raw(s) => s,
269 }
270 }
271}
272
273pub fn format_task_tree_markdown(tree: &TaskTree) -> String {
275 let mut md = String::new();
276
277 md.push_str(&format!("# {}\n", tree.task.title));
279
280 let mut meta_parts = Vec::new();
282 meta_parts.push(tree.task.status.to_uppercase());
283 if tree.task.priority != PRIORITY_DEFAULT {
284 meta_parts.push(format!("P{}", tree.task.priority));
285 }
286 if let Some(points) = tree.task.points {
287 meta_parts.push(format!("{} pts", points));
288 }
289 if let Some(ref owner) = tree.task.worker_id {
290 meta_parts.push(format!("@{}", owner));
291 }
292
293 if !meta_parts.is_empty() {
294 md.push_str(&format!("_{}_\n", meta_parts.join(", ")));
295 }
296
297 if let Some(ref desc) = tree.task.description {
298 md.push_str(&format!("\n{}\n", desc));
299 }
300
301 if !tree.children.is_empty() {
303 md.push('\n');
304 format_tree_children(&tree.children, "", &mut md);
305 }
306
307 md
308}
309
310fn format_tree_children(children: &[TaskTree], prefix: &str, md: &mut String) {
312 let count = children.len();
313
314 for (i, child) in children.iter().enumerate() {
315 let is_last = i == count - 1;
316 let connector = if is_last { "└── " } else { "├── " };
317 let child_prefix = if is_last { " " } else { "│ " };
318
319 let mut meta_parts = Vec::new();
321 meta_parts.push(child.task.status.clone());
322 if child.task.priority != PRIORITY_DEFAULT {
323 meta_parts.push(format!("P{}", child.task.priority));
324 }
325 if let Some(points) = child.task.points {
326 meta_parts.push(format!("{} pts", points));
327 }
328 if let Some(ref owner) = child.task.worker_id {
329 meta_parts.push(format!("@{}", owner));
330 }
331
332 let meta_str = if !meta_parts.is_empty() {
333 format!(" [{}]", meta_parts.join(", "))
334 } else {
335 String::new()
336 };
337
338 md.push_str(&format!(
339 "{}{}{}{}\n",
340 prefix, connector, child.task.title, meta_str
341 ));
342
343 if !child.children.is_empty() {
345 format_tree_children(&child.children, &format!("{}{}", prefix, child_prefix), md);
346 }
347 }
348}
349
350pub fn format_scan_result_markdown(result: &ScanResult) -> String {
352 let mut md = String::new();
353
354 md.push_str(&format!("# Scan: {}\\n", result.root.title));
356 md.push_str(&format!("- **id**: `{}`\\n", result.root.id));
357 md.push_str(&format!("- **status**: {}\\n", result.root.status));
358 md.push_str(&format!("- **priority**: {}\\n", result.root.priority));
359
360 if let Some(ref owner) = result.root.worker_id {
361 md.push_str(&format!("- **owner**: {}\\n", owner));
362 }
363
364 if let Some(ref desc) = result.root.description {
365 md.push_str(&format!("\\n{}\\n", desc));
366 }
367
368 if !result.before.is_empty() {
370 md.push_str(&format!("\\n## Before ({} tasks)\\n", result.before.len()));
371 md.push_str("_Tasks that block this task via blocks/follows dependencies_\\n\\n");
372 for task in &result.before {
373 md.push_str(&format_scan_task_short(task));
374 }
375 }
376
377 if !result.after.is_empty() {
379 md.push_str(&format!("\\n## After ({} tasks)\\n", result.after.len()));
380 md.push_str("_Tasks that this task blocks via blocks/follows dependencies_\\n\\n");
381 for task in &result.after {
382 md.push_str(&format_scan_task_short(task));
383 }
384 }
385
386 if !result.above.is_empty() {
388 md.push_str(&format!("\\n## Above ({} tasks)\\n", result.above.len()));
389 md.push_str("_Parent chain via contains dependency_\\n\\n");
390 for task in &result.above {
391 md.push_str(&format_scan_task_short(task));
392 }
393 }
394
395 if !result.below.is_empty() {
397 md.push_str(&format!("\\n## Below ({} tasks)\\n", result.below.len()));
398 md.push_str("_Descendants via contains dependency_\\n\\n");
399 for task in &result.below {
400 md.push_str(&format_scan_task_short(task));
401 }
402 }
403
404 let total = result.before.len() + result.after.len() + result.above.len() + result.below.len();
406 md.push_str(&format!("\\n---\\n**Total related tasks**: {}\\n", total));
407
408 md
409}
410
411fn format_scan_task_short(task: &Task) -> String {
413 let priority_marker = if task.priority > 0 { "!!! " } else { "" };
414
415 let owner = task
416 .worker_id
417 .as_ref()
418 .map(|o| format!(" @{}", o))
419 .unwrap_or_default();
420
421 let points = task
422 .points
423 .map(|p| format!(" ({} pts)", p))
424 .unwrap_or_default();
425
426 format!(
427 "- {}{} `{}` [{}]{}{}\\n",
428 priority_marker,
429 task.title,
430 &task.id[..8.min(task.id.len())],
431 task.status,
432 owner,
433 points,
434 )
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::types::{PRIORITY_DEFAULT, Priority, Task, TaskTree};
441
442 fn make_test_task(
443 id: &str,
444 title: &str,
445 status: &str,
446 priority: Priority,
447 points: Option<i32>,
448 ) -> Task {
449 Task {
450 id: id.to_string(),
451 title: title.to_string(),
452 description: None,
453 status: status.to_string(),
454 priority,
455 worker_id: None,
456 claimed_at: None,
457 needed_tags: vec![],
458 wanted_tags: vec![],
459 tags: vec![],
460 points,
461 time_estimate_ms: None,
462 time_actual_ms: None,
463 started_at: None,
464 completed_at: None,
465 current_thought: None,
466 cost_usd: 0.0,
467 metrics: [0; 8],
468 created_at: 0,
469 updated_at: 0,
470 }
471 }
472
473 #[test]
474 fn test_format_task_tree_markdown_root_only() {
475 let tree = TaskTree {
476 task: make_test_task("root-1", "Root Task", "pending", 8, Some(5)),
477 children: vec![],
478 };
479
480 let result = format_task_tree_markdown(&tree);
481 assert!(result.contains("# Root Task"));
482 assert!(result.contains("PENDING"));
483 assert!(result.contains("P8"));
484 assert!(result.contains("5 pts"));
485 }
486
487 #[test]
488 fn test_format_task_tree_markdown_with_children() {
489 let tree = TaskTree {
490 task: make_test_task(
491 "root-1",
492 "API Refactoring Sprint",
493 "in_progress",
494 8,
495 Some(16),
496 ),
497 children: vec![
498 TaskTree {
499 task: make_test_task("child-1", "Tier 1: Prerequisites", "pending", 8, Some(9)),
500 children: vec![
501 TaskTree {
502 task: make_test_task(
503 "grandchild-1",
504 "Refactor connect",
505 "completed",
506 PRIORITY_DEFAULT,
507 Some(3),
508 ),
509 children: vec![],
510 },
511 TaskTree {
512 task: make_test_task(
513 "grandchild-2",
514 "Merge claim/release",
515 "pending",
516 PRIORITY_DEFAULT,
517 Some(5),
518 ),
519 children: vec![],
520 },
521 ],
522 },
523 TaskTree {
524 task: make_test_task(
525 "child-2",
526 "Tier 2: Navigation",
527 "pending",
528 PRIORITY_DEFAULT,
529 Some(7),
530 ),
531 children: vec![],
532 },
533 ],
534 };
535
536 let result = format_task_tree_markdown(&tree);
537
538 assert!(result.contains("# API Refactoring Sprint"));
540 assert!(result.contains("IN_PROGRESS"));
541
542 assert!(result.contains("├── Tier 1: Prerequisites"));
544 assert!(result.contains("└── Tier 2: Navigation"));
545
546 assert!(result.contains("│ ├── Refactor connect"));
548 assert!(result.contains("│ └── Merge claim/release"));
549 }
550
551 #[test]
552 fn test_format_task_tree_markdown_deep_nesting() {
553 let tree = TaskTree {
554 task: make_test_task("root", "Root", "pending", PRIORITY_DEFAULT, None),
555 children: vec![TaskTree {
556 task: make_test_task("l1", "Level 1", "pending", PRIORITY_DEFAULT, None),
557 children: vec![TaskTree {
558 task: make_test_task("l2", "Level 2", "pending", PRIORITY_DEFAULT, None),
559 children: vec![TaskTree {
560 task: make_test_task("l3", "Level 3", "pending", PRIORITY_DEFAULT, None),
561 children: vec![],
562 }],
563 }],
564 }],
565 };
566
567 let result = format_task_tree_markdown(&tree);
568
569 assert!(result.contains("└── Level 1"));
571 assert!(result.contains(" └── Level 2"));
572 assert!(result.contains(" └── Level 3"));
573 }
574}