1use crate::config::StatesConfig;
4use crate::types::{ScanResult, Task, TaskTree, WorkerInfo, PRIORITY_DEFAULT};
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 from_str(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 if let Some(state_tasks) = by_status.get(state) {
91 if !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 }
101
102 if let Some(state_tasks) = by_status.get(&states_config.initial) {
104 if !state_tasks.is_empty() {
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
116 for state in states_config.state_names() {
118 if !states_config.is_blocking_state(state) && state != &states_config.initial {
119 if let Some(state_tasks) = by_status.get(state) {
120 if !state_tasks.is_empty() {
121 md.push_str(&format!("## {}\n\n", format_state_name(state)));
122 for (task, blocked_by) in state_tasks {
123 md.push_str(&format_task_short(task, blocked_by));
124 }
125 md.push('\n');
126 }
127 }
128 }
129 }
130
131 md
132}
133
134fn format_state_name(state: &str) -> String {
136 state
137 .split('_')
138 .map(|word| {
139 let mut chars = word.chars();
140 match chars.next() {
141 None => String::new(),
142 Some(first) => first.to_uppercase().chain(chars).collect(),
143 }
144 })
145 .collect::<Vec<_>>()
146 .join(" ")
147}
148
149fn format_task_short(task: &Task, blocked_by: &[String]) -> String {
151 let priority_marker = if task.priority > 0 {
152 "!!! "
153 } else {
154 ""
155 };
156
157 let blocked = if blocked_by.is_empty() {
158 String::new()
159 } else {
160 format!(" [blocked by {}]", blocked_by.len())
161 };
162
163 let owner = task.worker_id.as_ref()
164 .map(|o| format!(" @{}", o))
165 .unwrap_or_default();
166
167 let thought = task.current_thought.as_ref()
168 .map(|t| format!(" - _{}_", t))
169 .unwrap_or_default();
170
171 format!(
172 "- {}{} `{}`{}{}{}\n",
173 priority_marker,
174 task.title,
175 &task.id[..8.min(task.id.len())],
176 owner,
177 blocked,
178 thought,
179 )
180}
181
182pub fn format_workers_markdown(workers: &[WorkerInfo]) -> String {
184 let mut md = String::new();
185
186 md.push_str(&format!("# Workers ({})\n\n", workers.len()));
187
188 for worker in workers {
189 md.push_str(&format!("## {}\n", worker.id));
190 md.push_str(&format!("- **id**: `{}`\n", worker.id));
191
192 if !worker.tags.is_empty() {
193 md.push_str(&format!("- **tags**: {}\n", worker.tags.join(", ")));
194 }
195
196 md.push_str(&format!("- **claims**: {}/{}\n", worker.claim_count, worker.max_claims));
197
198 if let Some(ref thought) = worker.current_thought {
199 md.push_str(&format!("- **doing**: {}\n", thought));
200 }
201
202 md.push('\n');
203 }
204
205 md
206}
207
208
209pub fn format_attachments_markdown(attachments: &[crate::types::AttachmentMeta]) -> String {
211 let mut md = String::new();
212
213 md.push_str(&format!("# Attachments ({})\n\n", attachments.len()));
214
215 if attachments.is_empty() {
216 md.push_str("_No attachments found._\n");
217 return md;
218 }
219
220 for attachment in attachments {
221 md.push_str(&format!("## {}\n", attachment.name));
222 md.push_str(&format!("- **index**: {}\n", attachment.order_index));
223 md.push_str(&format!("- **mime**: {}\n", attachment.mime_type));
224
225 if let Some(ref fp) = attachment.file_path {
226 md.push_str(&format!("- **file**: `{}`\n", fp));
227 }
228
229 let created_secs = attachment.created_at / 1000;
231 md.push_str(&format!("- **created**: {}\n", created_secs));
232
233 md.push('\n');
234 }
235
236 md
237}
238
239pub fn markdown_to_json(md: String) -> Value {
241 serde_json::json!({
242 "format": "markdown",
243 "content": md
244 })
245}
246
247#[derive(Debug)]
249pub enum ToolResult {
250 Json(Value),
252 Raw(String),
254}
255
256impl ToolResult {
257 pub fn json(value: Value) -> Self {
259 ToolResult::Json(value)
260 }
261
262 pub fn raw(text: String) -> Self {
264 ToolResult::Raw(text)
265 }
266
267 pub fn into_string(self) -> String {
269 match self {
270 ToolResult::Json(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
271 ToolResult::Raw(s) => s,
272 }
273 }
274}
275
276pub fn format_task_tree_markdown(tree: &TaskTree) -> String {
278 let mut md = String::new();
279
280 md.push_str(&format!("# {}\n", tree.task.title));
282
283 let mut meta_parts = Vec::new();
285 meta_parts.push(tree.task.status.to_uppercase());
286 if tree.task.priority != PRIORITY_DEFAULT {
287 meta_parts.push(format!("P{}", tree.task.priority));
288 }
289 if let Some(points) = tree.task.points {
290 meta_parts.push(format!("{} pts", points));
291 }
292 if let Some(ref owner) = tree.task.worker_id {
293 meta_parts.push(format!("@{}", owner));
294 }
295
296 if !meta_parts.is_empty() {
297 md.push_str(&format!("_{}_\n", meta_parts.join(", ")));
298 }
299
300 if let Some(ref desc) = tree.task.description {
301 md.push_str(&format!("\n{}\n", desc));
302 }
303
304 if !tree.children.is_empty() {
306 md.push('\n');
307 format_tree_children(&tree.children, "", &mut md);
308 }
309
310 md
311}
312
313fn format_tree_children(children: &[TaskTree], prefix: &str, md: &mut String) {
315 let count = children.len();
316
317 for (i, child) in children.iter().enumerate() {
318 let is_last = i == count - 1;
319 let connector = if is_last { "└── " } else { "├── " };
320 let child_prefix = if is_last { " " } else { "│ " };
321
322 let mut meta_parts = Vec::new();
324 meta_parts.push(child.task.status.clone());
325 if child.task.priority != PRIORITY_DEFAULT {
326 meta_parts.push(format!("P{}", child.task.priority));
327 }
328 if let Some(points) = child.task.points {
329 meta_parts.push(format!("{} pts", points));
330 }
331 if let Some(ref owner) = child.task.worker_id {
332 meta_parts.push(format!("@{}", owner));
333 }
334
335 let meta_str = if !meta_parts.is_empty() {
336 format!(" [{}]", meta_parts.join(", "))
337 } else {
338 String::new()
339 };
340
341 md.push_str(&format!("{}{}{}{}\n", prefix, connector, child.task.title, meta_str));
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 {
414 "!!! "
415 } else {
416 ""
417 };
418
419 let owner = task.worker_id.as_ref()
420 .map(|o| format!(" @{}", o))
421 .unwrap_or_default();
422
423 let points = task.points
424 .map(|p| format!(" ({} pts)", p))
425 .unwrap_or_default();
426
427 format!(
428 "- {}{} `{}` [{}]{}{}\\n",
429 priority_marker,
430 task.title,
431 &task.id[..8.min(task.id.len())],
432 task.status,
433 owner,
434 points,
435 )
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::types::{Priority, Task, TaskTree, PRIORITY_DEFAULT};
442
443 fn make_test_task(id: &str, title: &str, status: &str, priority: Priority, points: Option<i32>) -> Task {
444 Task {
445 id: id.to_string(),
446 title: title.to_string(),
447 description: None,
448 status: status.to_string(),
449 priority,
450 worker_id: None,
451 claimed_at: None,
452 needed_tags: vec![],
453 wanted_tags: vec![],
454 tags: vec![],
455 points,
456 time_estimate_ms: None,
457 time_actual_ms: None,
458 started_at: None,
459 completed_at: None,
460 current_thought: None,
461 cost_usd: 0.0,
462 metrics: [0; 8],
463 created_at: 0,
464 updated_at: 0,
465 }
466 }
467
468 #[test]
469 fn test_format_task_tree_markdown_root_only() {
470 let tree = TaskTree {
471 task: make_test_task("root-1", "Root Task", "pending", 8, Some(5)),
472 children: vec![],
473 };
474
475 let result = format_task_tree_markdown(&tree);
476 assert!(result.contains("# Root Task"));
477 assert!(result.contains("PENDING"));
478 assert!(result.contains("P8"));
479 assert!(result.contains("5 pts"));
480 }
481
482 #[test]
483 fn test_format_task_tree_markdown_with_children() {
484 let tree = TaskTree {
485 task: make_test_task("root-1", "API Refactoring Sprint", "in_progress", 8, Some(16)),
486 children: vec![
487 TaskTree {
488 task: make_test_task("child-1", "Tier 1: Prerequisites", "pending", 8, Some(9)),
489 children: vec![
490 TaskTree {
491 task: make_test_task("grandchild-1", "Refactor connect", "completed", PRIORITY_DEFAULT, Some(3)),
492 children: vec![],
493 },
494 TaskTree {
495 task: make_test_task("grandchild-2", "Merge claim/release", "pending", PRIORITY_DEFAULT, Some(5)),
496 children: vec![],
497 },
498 ],
499 },
500 TaskTree {
501 task: make_test_task("child-2", "Tier 2: Navigation", "pending", PRIORITY_DEFAULT, Some(7)),
502 children: vec![],
503 },
504 ],
505 };
506
507 let result = format_task_tree_markdown(&tree);
508
509 assert!(result.contains("# API Refactoring Sprint"));
511 assert!(result.contains("IN_PROGRESS"));
512
513 assert!(result.contains("├── Tier 1: Prerequisites"));
515 assert!(result.contains("└── Tier 2: Navigation"));
516
517 assert!(result.contains("│ ├── Refactor connect"));
519 assert!(result.contains("│ └── Merge claim/release"));
520 }
521
522 #[test]
523 fn test_format_task_tree_markdown_deep_nesting() {
524 let tree = TaskTree {
525 task: make_test_task("root", "Root", "pending", PRIORITY_DEFAULT, None),
526 children: vec![
527 TaskTree {
528 task: make_test_task("l1", "Level 1", "pending", PRIORITY_DEFAULT, None),
529 children: vec![
530 TaskTree {
531 task: make_test_task("l2", "Level 2", "pending", PRIORITY_DEFAULT, None),
532 children: vec![
533 TaskTree {
534 task: make_test_task("l3", "Level 3", "pending", PRIORITY_DEFAULT, None),
535 children: vec![],
536 },
537 ],
538 },
539 ],
540 },
541 ],
542 };
543
544 let result = format_task_tree_markdown(&tree);
545
546 assert!(result.contains("└── Level 1"));
548 assert!(result.contains(" └── Level 2"));
549 assert!(result.contains(" └── Level 3"));
550 }
551}