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 {
93 md.push_str(&format!("## {}\n\n", format_state_name(state)));
94 for (task, blocked_by) in state_tasks {
95 md.push_str(&format_task_short(task, blocked_by));
96 }
97 md.push('\n');
98 }
99 }
100
101 if let Some(state_tasks) = by_status.get(&states_config.initial)
103 && !state_tasks.is_empty()
104 {
105 md.push_str(&format!(
106 "## {}\n\n",
107 format_state_name(&states_config.initial)
108 ));
109 for (task, blocked_by) in state_tasks {
110 md.push_str(&format_task_short(task, blocked_by));
111 }
112 md.push('\n');
113 }
114
115 for state in states_config.state_names() {
117 if !states_config.is_blocking_state(state)
118 && state != states_config.initial
119 && let Some(state_tasks) = by_status.get(state)
120 && !state_tasks.is_empty()
121 {
122 md.push_str(&format!("## {}\n\n", format_state_name(state)));
123 for (task, blocked_by) in state_tasks {
124 md.push_str(&format_task_short(task, blocked_by));
125 }
126 md.push('\n');
127 }
128 }
129
130 md
131}
132
133fn format_state_name(state: &str) -> String {
135 state
136 .split('_')
137 .map(|word| {
138 let mut chars = word.chars();
139 match chars.next() {
140 None => String::new(),
141 Some(first) => first.to_uppercase().chain(chars).collect(),
142 }
143 })
144 .collect::<Vec<_>>()
145 .join(" ")
146}
147
148fn format_task_short(task: &Task, blocked_by: &[String]) -> String {
150 let priority_marker = if task.priority > 0 { "!!! " } else { "" };
151
152 let blocked = if blocked_by.is_empty() {
153 String::new()
154 } else {
155 format!(" [blocked by {}]", blocked_by.len())
156 };
157
158 let owner = task
159 .worker_id
160 .as_ref()
161 .map(|o| format!(" @{}", o))
162 .unwrap_or_default();
163
164 let thought = task
165 .current_thought
166 .as_ref()
167 .map(|t| format!(" - _{}_", t))
168 .unwrap_or_default();
169
170 format!(
171 "- {}{} `{}`{}{}{}\n",
172 priority_marker,
173 task.title,
174 &task.id[..8.min(task.id.len())],
175 owner,
176 blocked,
177 thought,
178 )
179}
180
181pub fn format_workers_markdown(workers: &[WorkerInfo]) -> String {
183 let mut md = String::new();
184
185 md.push_str(&format!("# Workers ({})\n\n", workers.len()));
186
187 for worker in workers {
188 md.push_str(&format!("## {}\n", worker.id));
189 md.push_str(&format!("- **id**: `{}`\n", worker.id));
190
191 if !worker.tags.is_empty() {
192 md.push_str(&format!("- **tags**: {}\n", worker.tags.join(", ")));
193 }
194
195 md.push_str(&format!(
196 "- **claims**: {}/{}\n",
197 worker.claim_count, worker.max_claims
198 ));
199
200 if let Some(ref thought) = worker.current_thought {
201 md.push_str(&format!("- **doing**: {}\n", thought));
202 }
203
204 md.push('\n');
205 }
206
207 md
208}
209
210pub fn format_attachments_markdown(attachments: &[crate::types::AttachmentMeta]) -> String {
212 let mut md = String::new();
213
214 md.push_str(&format!("# Attachments ({})\n\n", attachments.len()));
215
216 if attachments.is_empty() {
217 md.push_str("_No attachments found._\n");
218 return md;
219 }
220
221 for attachment in attachments {
222 let header = if attachment.name.is_empty() {
224 format!("{} [{}]", attachment.attachment_type, attachment.sequence)
225 } else {
226 format!(
227 "{} [{}]: {}",
228 attachment.attachment_type, attachment.sequence, attachment.name
229 )
230 };
231 md.push_str(&format!("## {}\n", header));
232 md.push_str(&format!("- **type**: {}\n", attachment.attachment_type));
233 md.push_str(&format!("- **sequence**: {}\n", attachment.sequence));
234 if !attachment.name.is_empty() {
235 md.push_str(&format!("- **name**: {}\n", attachment.name));
236 }
237 md.push_str(&format!("- **mime**: {}\n", attachment.mime_type));
238
239 if let Some(ref fp) = attachment.file_path {
240 md.push_str(&format!("- **file**: `{}`\n", fp));
241 }
242
243 let created_secs = attachment.created_at / 1000;
245 md.push_str(&format!("- **created**: {}\n", created_secs));
246
247 md.push('\n');
248 }
249
250 md
251}
252
253pub fn markdown_to_json(md: String) -> Value {
255 serde_json::json!({
256 "format": "markdown",
257 "content": md
258 })
259}
260
261#[derive(Debug)]
263pub enum ToolResult {
264 Json(Value),
266 Raw(String),
268}
269
270impl ToolResult {
271 pub fn json(value: Value) -> Self {
273 ToolResult::Json(value)
274 }
275
276 pub fn raw(text: String) -> Self {
278 ToolResult::Raw(text)
279 }
280
281 pub fn into_string(self) -> String {
283 match self {
284 ToolResult::Json(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
285 ToolResult::Raw(s) => s,
286 }
287 }
288}
289
290pub fn format_task_tree_markdown(tree: &TaskTree) -> String {
292 let mut md = String::new();
293
294 md.push_str(&format!("# {}\n", tree.task.title));
296
297 let mut meta_parts = Vec::new();
299 meta_parts.push(tree.task.status.to_uppercase());
300 if tree.task.priority != PRIORITY_DEFAULT {
301 meta_parts.push(format!("P{}", tree.task.priority));
302 }
303 if let Some(points) = tree.task.points {
304 meta_parts.push(format!("{} pts", points));
305 }
306 if let Some(ref owner) = tree.task.worker_id {
307 meta_parts.push(format!("@{}", owner));
308 }
309
310 if !meta_parts.is_empty() {
311 md.push_str(&format!("_{}_\n", meta_parts.join(", ")));
312 }
313
314 if let Some(ref desc) = tree.task.description {
315 md.push_str(&format!("\n{}\n", desc));
316 }
317
318 if !tree.children.is_empty() {
320 md.push('\n');
321 format_tree_children(&tree.children, "", &mut md);
322 }
323
324 md
325}
326
327fn format_tree_children(children: &[TaskTree], prefix: &str, md: &mut String) {
329 let count = children.len();
330
331 for (i, child) in children.iter().enumerate() {
332 let is_last = i == count - 1;
333 let connector = if is_last { "└── " } else { "├── " };
334 let child_prefix = if is_last { " " } else { "│ " };
335
336 let mut meta_parts = Vec::new();
338 meta_parts.push(child.task.status.clone());
339 if child.task.priority != PRIORITY_DEFAULT {
340 meta_parts.push(format!("P{}", child.task.priority));
341 }
342 if let Some(points) = child.task.points {
343 meta_parts.push(format!("{} pts", points));
344 }
345 if let Some(ref owner) = child.task.worker_id {
346 meta_parts.push(format!("@{}", owner));
347 }
348
349 let meta_str = if !meta_parts.is_empty() {
350 format!(" [{}]", meta_parts.join(", "))
351 } else {
352 String::new()
353 };
354
355 md.push_str(&format!(
356 "{}{}{}{}\n",
357 prefix, connector, child.task.title, meta_str
358 ));
359
360 if !child.children.is_empty() {
362 format_tree_children(&child.children, &format!("{}{}", prefix, child_prefix), md);
363 }
364 }
365}
366
367pub fn format_scan_result_markdown(result: &ScanResult) -> String {
369 let mut md = String::new();
370
371 md.push_str(&format!("# Scan: {}\\n", result.root.title));
373 md.push_str(&format!("- **id**: `{}`\\n", result.root.id));
374 md.push_str(&format!("- **status**: {}\\n", result.root.status));
375 md.push_str(&format!("- **priority**: {}\\n", result.root.priority));
376
377 if let Some(ref owner) = result.root.worker_id {
378 md.push_str(&format!("- **owner**: {}\\n", owner));
379 }
380
381 if let Some(ref desc) = result.root.description {
382 md.push_str(&format!("\\n{}\\n", desc));
383 }
384
385 if !result.before.is_empty() {
387 md.push_str(&format!("\\n## Before ({} tasks)\\n", result.before.len()));
388 md.push_str("_Tasks that block this task via blocks/follows dependencies_\\n\\n");
389 for task in &result.before {
390 md.push_str(&format_scan_task_short(task));
391 }
392 }
393
394 if !result.after.is_empty() {
396 md.push_str(&format!("\\n## After ({} tasks)\\n", result.after.len()));
397 md.push_str("_Tasks that this task blocks via blocks/follows dependencies_\\n\\n");
398 for task in &result.after {
399 md.push_str(&format_scan_task_short(task));
400 }
401 }
402
403 if !result.above.is_empty() {
405 md.push_str(&format!("\\n## Above ({} tasks)\\n", result.above.len()));
406 md.push_str("_Parent chain via contains dependency_\\n\\n");
407 for task in &result.above {
408 md.push_str(&format_scan_task_short(task));
409 }
410 }
411
412 if !result.below.is_empty() {
414 md.push_str(&format!("\\n## Below ({} tasks)\\n", result.below.len()));
415 md.push_str("_Descendants via contains dependency_\\n\\n");
416 for task in &result.below {
417 md.push_str(&format_scan_task_short(task));
418 }
419 }
420
421 let total = result.before.len() + result.after.len() + result.above.len() + result.below.len();
423 md.push_str(&format!("\\n---\\n**Total related tasks**: {}\\n", total));
424
425 md
426}
427
428fn format_scan_task_short(task: &Task) -> String {
430 let priority_marker = if task.priority > 0 { "!!! " } else { "" };
431
432 let owner = task
433 .worker_id
434 .as_ref()
435 .map(|o| format!(" @{}", o))
436 .unwrap_or_default();
437
438 let points = task
439 .points
440 .map(|p| format!(" ({} pts)", p))
441 .unwrap_or_default();
442
443 format!(
444 "- {}{} `{}` [{}]{}{}\\n",
445 priority_marker,
446 task.title,
447 &task.id[..8.min(task.id.len())],
448 task.status,
449 owner,
450 points,
451 )
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::types::{PRIORITY_DEFAULT, Priority, Task, TaskTree};
458
459 fn make_test_task(
460 id: &str,
461 title: &str,
462 status: &str,
463 priority: Priority,
464 points: Option<i32>,
465 ) -> Task {
466 Task {
467 id: id.to_string(),
468 title: title.to_string(),
469 description: None,
470 status: status.to_string(),
471 phase: None,
472 priority,
473 worker_id: None,
474 claimed_at: None,
475 needed_tags: vec![],
476 wanted_tags: vec![],
477 tags: vec![],
478 points,
479 time_estimate_ms: None,
480 time_actual_ms: None,
481 started_at: None,
482 completed_at: None,
483 current_thought: None,
484 cost_usd: 0.0,
485 metrics: [0; 8],
486 created_at: 0,
487 updated_at: 0,
488 }
489 }
490
491 #[test]
492 fn test_format_task_tree_markdown_root_only() {
493 let tree = TaskTree {
494 task: make_test_task("root-1", "Root Task", "pending", 8, Some(5)),
495 children: vec![],
496 };
497
498 let result = format_task_tree_markdown(&tree);
499 assert!(result.contains("# Root Task"));
500 assert!(result.contains("PENDING"));
501 assert!(result.contains("P8"));
502 assert!(result.contains("5 pts"));
503 }
504
505 #[test]
506 fn test_format_task_tree_markdown_with_children() {
507 let tree = TaskTree {
508 task: make_test_task("root-1", "API Refactoring Sprint", "working", 8, Some(16)),
509 children: vec![
510 TaskTree {
511 task: make_test_task("child-1", "Tier 1: Prerequisites", "pending", 8, Some(9)),
512 children: vec![
513 TaskTree {
514 task: make_test_task(
515 "grandchild-1",
516 "Refactor connect",
517 "completed",
518 PRIORITY_DEFAULT,
519 Some(3),
520 ),
521 children: vec![],
522 },
523 TaskTree {
524 task: make_test_task(
525 "grandchild-2",
526 "Merge claim/release",
527 "pending",
528 PRIORITY_DEFAULT,
529 Some(5),
530 ),
531 children: vec![],
532 },
533 ],
534 },
535 TaskTree {
536 task: make_test_task(
537 "child-2",
538 "Tier 2: Navigation",
539 "pending",
540 PRIORITY_DEFAULT,
541 Some(7),
542 ),
543 children: vec![],
544 },
545 ],
546 };
547
548 let result = format_task_tree_markdown(&tree);
549
550 assert!(result.contains("# API Refactoring Sprint"));
552 assert!(result.contains("WORKING"));
553
554 assert!(result.contains("├── Tier 1: Prerequisites"));
556 assert!(result.contains("└── Tier 2: Navigation"));
557
558 assert!(result.contains("│ ├── Refactor connect"));
560 assert!(result.contains("│ └── Merge claim/release"));
561 }
562
563 #[test]
564 fn test_format_task_tree_markdown_deep_nesting() {
565 let tree = TaskTree {
566 task: make_test_task("root", "Root", "pending", PRIORITY_DEFAULT, None),
567 children: vec![TaskTree {
568 task: make_test_task("l1", "Level 1", "pending", PRIORITY_DEFAULT, None),
569 children: vec![TaskTree {
570 task: make_test_task("l2", "Level 2", "pending", PRIORITY_DEFAULT, None),
571 children: vec![TaskTree {
572 task: make_test_task("l3", "Level 3", "pending", PRIORITY_DEFAULT, None),
573 children: vec![],
574 }],
575 }],
576 }],
577 };
578
579 let result = format_task_tree_markdown(&tree);
580
581 assert!(result.contains("└── Level 1"));
583 assert!(result.contains(" └── Level 2"));
584 assert!(result.contains(" └── Level 3"));
585 }
586}