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