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
133pub const MAX_TITLE_DISPLAY_LEN: usize = 80;
135
136pub fn truncate_title(title: &str) -> std::borrow::Cow<'_, str> {
140 let first_line = title.split('\n').next().unwrap_or(title).trim();
142 if first_line.len() <= MAX_TITLE_DISPLAY_LEN {
143 std::borrow::Cow::Borrowed(first_line)
144 } else {
145 let truncated = &first_line[..first_line.floor_char_boundary(MAX_TITLE_DISPLAY_LEN)];
147 std::borrow::Cow::Owned(format!("{}...", truncated.trim_end()))
148 }
149}
150
151fn format_state_name(state: &str) -> String {
153 state
154 .split('_')
155 .map(|word| {
156 let mut chars = word.chars();
157 match chars.next() {
158 None => String::new(),
159 Some(first) => first.to_uppercase().chain(chars).collect(),
160 }
161 })
162 .collect::<Vec<_>>()
163 .join(" ")
164}
165
166fn priority_marker(priority: i32) -> &'static str {
168 match priority {
169 10 => "!!! ",
170 8..=9 => "!! ",
171 6..=7 => "! ",
172 _ => "",
173 }
174}
175
176fn format_task_short(task: &Task, blocked_by: &[String]) -> String {
178 let priority_marker = priority_marker(task.priority);
179
180 let blocked = if blocked_by.is_empty() {
181 String::new()
182 } else if blocked_by.len() == 1 {
183 format!(" [blocked by {}]", blocked_by[0])
184 } else {
185 format!(" [blocked by {}]", blocked_by.len())
186 };
187
188 let owner = task
189 .worker_id
190 .as_ref()
191 .map(|o| format!(" @{}", o))
192 .unwrap_or_default();
193
194 let thought = task
195 .current_thought
196 .as_ref()
197 .map(|t| format!(" - _{}_", t))
198 .unwrap_or_default();
199
200 format!(
201 "- {}{} `{}`{}{}{}\n",
202 priority_marker,
203 truncate_title(&task.title),
204 &task.id,
205 owner,
206 blocked,
207 thought,
208 )
209}
210
211pub fn format_workers_markdown(workers: &[WorkerInfo]) -> String {
213 let mut md = String::new();
214
215 md.push_str(&format!("# Workers ({})\n\n", workers.len()));
216
217 for worker in workers {
218 md.push_str(&format!("## {}\n", worker.id));
219 md.push_str(&format!("- **id**: `{}`\n", worker.id));
220
221 if !worker.tags.is_empty() {
222 md.push_str(&format!("- **tags**: {}\n", worker.tags.join(", ")));
223 }
224
225 if let Some(ref workflow) = worker.workflow {
226 md.push_str(&format!("- **workflow**: {}\n", workflow));
227 }
228
229 md.push_str(&format!(
230 "- **claims**: {}/{}\n",
231 worker.claim_count, worker.max_claims
232 ));
233
234 if let Some(ref thought) = worker.current_thought {
235 md.push_str(&format!("- **doing**: {}\n", thought));
236 }
237
238 md.push('\n');
239 }
240
241 md
242}
243
244pub fn format_attachments_markdown(attachments: &[crate::types::AttachmentMeta]) -> String {
246 let mut md = String::new();
247
248 md.push_str(&format!("# Attachments ({})\n\n", attachments.len()));
249
250 if attachments.is_empty() {
251 md.push_str("_No attachments found._\n");
252 return md;
253 }
254
255 for attachment in attachments {
256 let header = if attachment.name.is_empty() {
258 format!("{} [{}]", attachment.attachment_type, attachment.sequence)
259 } else {
260 format!(
261 "{} [{}]: {}",
262 attachment.attachment_type, attachment.sequence, attachment.name
263 )
264 };
265 md.push_str(&format!("## {}\n", header));
266 md.push_str(&format!("- **type**: {}\n", attachment.attachment_type));
267 md.push_str(&format!("- **sequence**: {}\n", attachment.sequence));
268 if !attachment.name.is_empty() {
269 md.push_str(&format!("- **name**: {}\n", attachment.name));
270 }
271 md.push_str(&format!("- **mime**: {}\n", attachment.mime_type));
272
273 if let Some(ref fp) = attachment.file_path {
274 md.push_str(&format!("- **file**: `{}`\n", fp));
275 }
276
277 md.push_str(&format!(
279 "- **created**: {}\n",
280 crate::types::ms_to_iso(attachment.created_at)
281 ));
282
283 md.push('\n');
284 }
285
286 md
287}
288
289#[derive(Debug)]
291pub enum ToolResult {
292 Json(Value),
294 Raw(String),
296}
297
298impl ToolResult {
299 pub fn json(value: Value) -> Self {
301 ToolResult::Json(value)
302 }
303
304 pub fn raw(text: String) -> Self {
306 ToolResult::Raw(text)
307 }
308
309 pub fn into_string(self) -> String {
311 match self {
312 ToolResult::Json(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
313 ToolResult::Raw(s) => s,
314 }
315 }
316
317 pub fn into_json(self) -> Value {
319 match self {
320 ToolResult::Json(v) => v,
321 ToolResult::Raw(s) => panic!("Expected JSON result, got raw text: {}", s),
322 }
323 }
324
325 pub fn into_raw(self) -> String {
327 match self {
328 ToolResult::Raw(s) => s,
329 ToolResult::Json(_) => panic!("Expected raw text result, got JSON"),
330 }
331 }
332}
333
334pub fn format_task_tree_markdown(tree: &TaskTree) -> String {
336 let mut md = String::new();
337
338 md.push_str(&format!("# {}\n", tree.task.title));
340
341 let mut meta_parts = Vec::new();
343 meta_parts.push(tree.task.status.to_uppercase());
344 if tree.task.priority != PRIORITY_DEFAULT {
345 meta_parts.push(format!("P{}", tree.task.priority));
346 }
347 if let Some(points) = tree.task.points {
348 meta_parts.push(format!("{} pts", points));
349 }
350 if let Some(ref owner) = tree.task.worker_id {
351 meta_parts.push(format!("@{}", owner));
352 }
353
354 if !meta_parts.is_empty() {
355 md.push_str(&format!("_{}_\n", meta_parts.join(", ")));
356 }
357
358 if let Some(ref desc) = tree.task.description {
359 md.push_str(&format!("\n{}\n", desc));
360 }
361
362 if !tree.children.is_empty() {
364 md.push('\n');
365 format_tree_children(&tree.children, "", &mut md);
366 }
367
368 md
369}
370
371fn format_tree_children(children: &[TaskTree], prefix: &str, md: &mut String) {
373 let count = children.len();
374
375 for (i, child) in children.iter().enumerate() {
376 let is_last = i == count - 1;
377 let connector = if is_last { "└── " } else { "├── " };
378 let child_prefix = if is_last { " " } else { "│ " };
379
380 let mut meta_parts = Vec::new();
382 meta_parts.push(child.task.status.clone());
383 if child.task.priority != PRIORITY_DEFAULT {
384 meta_parts.push(format!("P{}", child.task.priority));
385 }
386 if let Some(points) = child.task.points {
387 meta_parts.push(format!("{} pts", points));
388 }
389 if let Some(ref owner) = child.task.worker_id {
390 meta_parts.push(format!("@{}", owner));
391 }
392
393 let meta_str = if !meta_parts.is_empty() {
394 format!(" [{}]", meta_parts.join(", "))
395 } else {
396 String::new()
397 };
398
399 md.push_str(&format!(
400 "{}{}{}{}\n",
401 prefix, connector, child.task.title, meta_str
402 ));
403
404 if !child.children.is_empty() {
406 format_tree_children(&child.children, &format!("{}{}", prefix, child_prefix), md);
407 }
408 }
409}
410
411pub fn format_scan_result_markdown(result: &ScanResult) -> String {
413 let mut md = String::new();
414
415 md.push_str(&format!("# Scan: {}\\n", result.root.title));
417 md.push_str(&format!("- **id**: `{}`\\n", result.root.id));
418 md.push_str(&format!("- **status**: {}\\n", result.root.status));
419 md.push_str(&format!("- **priority**: {}\\n", result.root.priority));
420
421 if let Some(ref owner) = result.root.worker_id {
422 md.push_str(&format!("- **owner**: {}\\n", owner));
423 }
424
425 if let Some(ref desc) = result.root.description {
426 md.push_str(&format!("\\n{}\\n", desc));
427 }
428
429 if !result.before.is_empty() {
431 md.push_str(&format!("\\n## Before ({} tasks)\\n", result.before.len()));
432 md.push_str("_Tasks that block this task via blocks/follows dependencies_\\n\\n");
433 for task in &result.before {
434 md.push_str(&format_scan_task_short(task));
435 }
436 }
437
438 if !result.after.is_empty() {
440 md.push_str(&format!("\\n## After ({} tasks)\\n", result.after.len()));
441 md.push_str("_Tasks that this task blocks via blocks/follows dependencies_\\n\\n");
442 for task in &result.after {
443 md.push_str(&format_scan_task_short(task));
444 }
445 }
446
447 if !result.above.is_empty() {
449 md.push_str(&format!("\\n## Above ({} tasks)\\n", result.above.len()));
450 md.push_str("_Parent chain via contains dependency_\\n\\n");
451 for task in &result.above {
452 md.push_str(&format_scan_task_short(task));
453 }
454 }
455
456 if !result.below.is_empty() {
458 md.push_str(&format!("\\n## Below ({} tasks)\\n", result.below.len()));
459 md.push_str("_Descendants via contains dependency_\\n\\n");
460 for task in &result.below {
461 md.push_str(&format_scan_task_short(task));
462 }
463 }
464
465 let total = result.before.len() + result.after.len() + result.above.len() + result.below.len();
467 md.push_str(&format!("\\n---\\n**Total related tasks**: {}\\n", total));
468
469 md
470}
471
472fn format_scan_task_short(task: &Task) -> String {
474 let priority_marker = priority_marker(task.priority);
475
476 let owner = task
477 .worker_id
478 .as_ref()
479 .map(|o| format!(" @{}", o))
480 .unwrap_or_default();
481
482 let points = task
483 .points
484 .map(|p| format!(" ({} pts)", p))
485 .unwrap_or_default();
486
487 format!(
488 "- {}{} `{}` [{}]{}{}\\n",
489 priority_marker,
490 truncate_title(&task.title),
491 &task.id,
492 task.status,
493 owner,
494 points,
495 )
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::types::{PRIORITY_DEFAULT, Priority, Task, TaskTree};
502
503 fn make_test_task(
504 id: &str,
505 title: &str,
506 status: &str,
507 priority: Priority,
508 points: Option<i32>,
509 ) -> Task {
510 Task {
511 id: id.to_string(),
512 title: title.to_string(),
513 description: None,
514 status: status.to_string(),
515 phase: None,
516 priority,
517 worker_id: None,
518 claimed_at: None,
519 needed_tags: vec![],
520 wanted_tags: vec![],
521 tags: vec![],
522 points,
523 time_estimate_ms: None,
524 time_actual_ms: None,
525 started_at: None,
526 completed_at: None,
527 current_thought: None,
528 cost_usd: 0.0,
529 metrics: [0; 8],
530 created_at: 0,
531 updated_at: 0,
532 }
533 }
534
535 #[test]
536 fn test_format_task_tree_markdown_root_only() {
537 let tree = TaskTree {
538 task: make_test_task("root-1", "Root Task", "pending", 8, Some(5)),
539 children: vec![],
540 };
541
542 let result = format_task_tree_markdown(&tree);
543 assert!(result.contains("# Root Task"));
544 assert!(result.contains("PENDING"));
545 assert!(result.contains("P8"));
546 assert!(result.contains("5 pts"));
547 }
548
549 #[test]
550 fn test_format_task_tree_markdown_with_children() {
551 let tree = TaskTree {
552 task: make_test_task("root-1", "API Refactoring Sprint", "working", 8, Some(16)),
553 children: vec![
554 TaskTree {
555 task: make_test_task("child-1", "Tier 1: Prerequisites", "pending", 8, Some(9)),
556 children: vec![
557 TaskTree {
558 task: make_test_task(
559 "grandchild-1",
560 "Refactor connect",
561 "completed",
562 PRIORITY_DEFAULT,
563 Some(3),
564 ),
565 children: vec![],
566 },
567 TaskTree {
568 task: make_test_task(
569 "grandchild-2",
570 "Merge claim/release",
571 "pending",
572 PRIORITY_DEFAULT,
573 Some(5),
574 ),
575 children: vec![],
576 },
577 ],
578 },
579 TaskTree {
580 task: make_test_task(
581 "child-2",
582 "Tier 2: Navigation",
583 "pending",
584 PRIORITY_DEFAULT,
585 Some(7),
586 ),
587 children: vec![],
588 },
589 ],
590 };
591
592 let result = format_task_tree_markdown(&tree);
593
594 assert!(result.contains("# API Refactoring Sprint"));
596 assert!(result.contains("WORKING"));
597
598 assert!(result.contains("├── Tier 1: Prerequisites"));
600 assert!(result.contains("└── Tier 2: Navigation"));
601
602 assert!(result.contains("│ ├── Refactor connect"));
604 assert!(result.contains("│ └── Merge claim/release"));
605 }
606
607 #[test]
608 fn test_format_task_tree_markdown_deep_nesting() {
609 let tree = TaskTree {
610 task: make_test_task("root", "Root", "pending", PRIORITY_DEFAULT, None),
611 children: vec![TaskTree {
612 task: make_test_task("l1", "Level 1", "pending", PRIORITY_DEFAULT, None),
613 children: vec![TaskTree {
614 task: make_test_task("l2", "Level 2", "pending", PRIORITY_DEFAULT, None),
615 children: vec![TaskTree {
616 task: make_test_task("l3", "Level 3", "pending", PRIORITY_DEFAULT, None),
617 children: vec![],
618 }],
619 }],
620 }],
621 };
622
623 let result = format_task_tree_markdown(&tree);
624
625 assert!(result.contains("└── Level 1"));
627 assert!(result.contains(" └── Level 2"));
628 assert!(result.contains(" └── Level 3"));
629 }
630
631 #[test]
632 fn test_truncate_title_short() {
633 let title = "Short title";
634 assert_eq!(truncate_title(title).as_ref(), "Short title");
635 }
636
637 #[test]
638 fn test_truncate_title_at_limit() {
639 let title = "A".repeat(MAX_TITLE_DISPLAY_LEN);
640 assert_eq!(truncate_title(&title).as_ref(), title.as_str());
641 }
642
643 #[test]
644 fn test_truncate_title_over_limit() {
645 let title = "A".repeat(MAX_TITLE_DISPLAY_LEN + 20);
646 let result = truncate_title(&title);
647 assert!(result.ends_with("..."));
648 assert!(result.len() <= MAX_TITLE_DISPLAY_LEN + 3);
649 }
650
651 #[test]
652 fn test_truncate_title_multiline() {
653 let title = "First line\nSecond line\nThird line";
654 assert_eq!(truncate_title(title).as_ref(), "First line");
655 }
656
657 #[test]
658 fn test_truncate_title_long_multiline() {
659 let long_first = "A".repeat(100);
660 let title = format!("{}\nSecond line", long_first);
661 let result = truncate_title(&title);
662 assert!(result.ends_with("..."));
663 assert!(result.len() <= MAX_TITLE_DISPLAY_LEN + 3);
664 }
665}