Skip to main content

task_graph_mcp/
format.rs

1//! Output formatting utilities for markdown and JSON.
2
3use crate::config::StatesConfig;
4use crate::types::{ScanResult, Task, TaskTree, WorkerInfo, PRIORITY_DEFAULT};
5use serde_json::Value;
6use std::collections::HashMap;
7
8/// Output format for query results.
9#[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
27/// Format a single task as markdown.
28pub 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
62/// Format a list of tasks as markdown.
63/// Groups tasks by their state dynamically based on the states config.
64pub 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    // Group tasks by status
73    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    // Output blocking states first (in-progress tasks), then initial state, then others
85    // This provides a sensible default ordering
86
87    // First, output blocking states (excluding initial state)
88    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    // Then initial state
103    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    // Then non-blocking states (terminal states like completed, failed, cancelled)
117    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
134/// Format a state name for display (capitalize, replace underscores with spaces).
135fn 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
149/// Format a task in short form for lists.
150fn 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
182/// Format workers as markdown.
183pub 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
209/// Format attachments as markdown.
210pub 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        // Format created_at as relative time if possible
230        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
239/// Convert markdown to JSON value for uniform response handling.
240pub fn markdown_to_json(md: String) -> Value {
241    serde_json::json!({
242        "format": "markdown",
243        "content": md
244    })
245}
246
247/// Result type for tool handlers - allows returning either JSON or raw text.
248#[derive(Debug)]
249pub enum ToolResult {
250    /// JSON value (will be serialized to JSON string)
251    Json(Value),
252    /// Raw text (returned as-is, typically markdown)
253    Raw(String),
254}
255
256impl ToolResult {
257    /// Create a JSON result
258    pub fn json(value: Value) -> Self {
259        ToolResult::Json(value)
260    }
261
262    /// Create a raw text result (for markdown)
263    pub fn raw(text: String) -> Self {
264        ToolResult::Raw(text)
265    }
266
267    /// Convert to the appropriate string representation
268    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
276/// Format a task tree as markdown with visual tree structure.
277pub fn format_task_tree_markdown(tree: &TaskTree) -> String {
278    let mut md = String::new();
279
280    // Format root task as heading
281    md.push_str(&format!("# {}\n", tree.task.title));
282
283    // Add root task metadata
284    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    // Format children with tree characters
305    if !tree.children.is_empty() {
306        md.push('\n');
307        format_tree_children(&tree.children, "", &mut md);
308    }
309
310    md
311}
312
313/// Recursively format children with tree structure characters.
314fn 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        // Build the task line with metadata
323        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        // Recursively format grandchildren
344        if !child.children.is_empty() {
345            format_tree_children(&child.children, &format!("{}{}", prefix, child_prefix), md);
346        }
347    }
348}
349
350/// Format a scan result as markdown.
351pub fn format_scan_result_markdown(result: &ScanResult) -> String {
352    let mut md = String::new();
353
354    // Root task header
355    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    // Before (predecessors)
369    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    // After (successors)
378    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    // Above (ancestors)
387    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    // Below (descendants)
396    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    // Summary
405    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
411/// Format a task in short form for scan results.
412fn 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        // Check root formatting
510        assert!(result.contains("# API Refactoring Sprint"));
511        assert!(result.contains("IN_PROGRESS"));
512
513        // Check tree structure characters
514        assert!(result.contains("├── Tier 1: Prerequisites"));
515        assert!(result.contains("└── Tier 2: Navigation"));
516
517        // Check grandchildren have proper indentation
518        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        // Check deep nesting with proper prefix
547        assert!(result.contains("└── Level 1"));
548        assert!(result.contains("    └── Level 2"));
549        assert!(result.contains("        └── Level 3"));
550    }
551}