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