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        {
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    // Then initial state
102    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    // Then non-blocking states (terminal states like completed, failed, cancelled)
116    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
133/// Format a state name for display (capitalize, replace underscores with spaces).
134fn format_state_name(state: &str) -> String {
135    state
136        .split('_')
137        .map(|word| {
138            let mut chars = word.chars();
139            match chars.next() {
140                None => String::new(),
141                Some(first) => first.to_uppercase().chain(chars).collect(),
142            }
143        })
144        .collect::<Vec<_>>()
145        .join(" ")
146}
147
148/// Format a task in short form for lists.
149fn format_task_short(task: &Task, blocked_by: &[String]) -> String {
150    let priority_marker = if task.priority > 0 { "!!! " } else { "" };
151
152    let blocked = if blocked_by.is_empty() {
153        String::new()
154    } else {
155        format!(" [blocked by {}]", blocked_by.len())
156    };
157
158    let owner = task
159        .worker_id
160        .as_ref()
161        .map(|o| format!(" @{}", o))
162        .unwrap_or_default();
163
164    let thought = task
165        .current_thought
166        .as_ref()
167        .map(|t| format!(" - _{}_", t))
168        .unwrap_or_default();
169
170    format!(
171        "- {}{} `{}`{}{}{}\n",
172        priority_marker,
173        task.title,
174        &task.id[..8.min(task.id.len())],
175        owner,
176        blocked,
177        thought,
178    )
179}
180
181/// Format workers as markdown.
182pub fn format_workers_markdown(workers: &[WorkerInfo]) -> String {
183    let mut md = String::new();
184
185    md.push_str(&format!("# Workers ({})\n\n", workers.len()));
186
187    for worker in workers {
188        md.push_str(&format!("## {}\n", worker.id));
189        md.push_str(&format!("- **id**: `{}`\n", worker.id));
190
191        if !worker.tags.is_empty() {
192            md.push_str(&format!("- **tags**: {}\n", worker.tags.join(", ")));
193        }
194
195        md.push_str(&format!(
196            "- **claims**: {}/{}\n",
197            worker.claim_count, worker.max_claims
198        ));
199
200        if let Some(ref thought) = worker.current_thought {
201            md.push_str(&format!("- **doing**: {}\n", thought));
202        }
203
204        md.push('\n');
205    }
206
207    md
208}
209
210/// Format attachments as markdown.
211pub fn format_attachments_markdown(attachments: &[crate::types::AttachmentMeta]) -> String {
212    let mut md = String::new();
213
214    md.push_str(&format!("# Attachments ({})\n\n", attachments.len()));
215
216    if attachments.is_empty() {
217        md.push_str("_No attachments found._\n");
218        return md;
219    }
220
221    for attachment in attachments {
222        // Use type as header, with name in parentheses if present
223        let header = if attachment.name.is_empty() {
224            format!("{} [{}]", attachment.attachment_type, attachment.sequence)
225        } else {
226            format!(
227                "{} [{}]: {}",
228                attachment.attachment_type, attachment.sequence, attachment.name
229            )
230        };
231        md.push_str(&format!("## {}\n", header));
232        md.push_str(&format!("- **type**: {}\n", attachment.attachment_type));
233        md.push_str(&format!("- **sequence**: {}\n", attachment.sequence));
234        if !attachment.name.is_empty() {
235            md.push_str(&format!("- **name**: {}\n", attachment.name));
236        }
237        md.push_str(&format!("- **mime**: {}\n", attachment.mime_type));
238
239        if let Some(ref fp) = attachment.file_path {
240            md.push_str(&format!("- **file**: `{}`\n", fp));
241        }
242
243        // Format created_at as relative time if possible
244        let created_secs = attachment.created_at / 1000;
245        md.push_str(&format!("- **created**: {}\n", created_secs));
246
247        md.push('\n');
248    }
249
250    md
251}
252
253/// Convert markdown to JSON value for uniform response handling.
254pub fn markdown_to_json(md: String) -> Value {
255    serde_json::json!({
256        "format": "markdown",
257        "content": md
258    })
259}
260
261/// Result type for tool handlers - allows returning either JSON or raw text.
262#[derive(Debug)]
263pub enum ToolResult {
264    /// JSON value (will be serialized to JSON string)
265    Json(Value),
266    /// Raw text (returned as-is, typically markdown)
267    Raw(String),
268}
269
270impl ToolResult {
271    /// Create a JSON result
272    pub fn json(value: Value) -> Self {
273        ToolResult::Json(value)
274    }
275
276    /// Create a raw text result (for markdown)
277    pub fn raw(text: String) -> Self {
278        ToolResult::Raw(text)
279    }
280
281    /// Convert to the appropriate string representation
282    pub fn into_string(self) -> String {
283        match self {
284            ToolResult::Json(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
285            ToolResult::Raw(s) => s,
286        }
287    }
288}
289
290/// Format a task tree as markdown with visual tree structure.
291pub fn format_task_tree_markdown(tree: &TaskTree) -> String {
292    let mut md = String::new();
293
294    // Format root task as heading
295    md.push_str(&format!("# {}\n", tree.task.title));
296
297    // Add root task metadata
298    let mut meta_parts = Vec::new();
299    meta_parts.push(tree.task.status.to_uppercase());
300    if tree.task.priority != PRIORITY_DEFAULT {
301        meta_parts.push(format!("P{}", tree.task.priority));
302    }
303    if let Some(points) = tree.task.points {
304        meta_parts.push(format!("{} pts", points));
305    }
306    if let Some(ref owner) = tree.task.worker_id {
307        meta_parts.push(format!("@{}", owner));
308    }
309
310    if !meta_parts.is_empty() {
311        md.push_str(&format!("_{}_\n", meta_parts.join(", ")));
312    }
313
314    if let Some(ref desc) = tree.task.description {
315        md.push_str(&format!("\n{}\n", desc));
316    }
317
318    // Format children with tree characters
319    if !tree.children.is_empty() {
320        md.push('\n');
321        format_tree_children(&tree.children, "", &mut md);
322    }
323
324    md
325}
326
327/// Recursively format children with tree structure characters.
328fn format_tree_children(children: &[TaskTree], prefix: &str, md: &mut String) {
329    let count = children.len();
330
331    for (i, child) in children.iter().enumerate() {
332        let is_last = i == count - 1;
333        let connector = if is_last { "└── " } else { "├── " };
334        let child_prefix = if is_last { "    " } else { "│   " };
335
336        // Build the task line with metadata
337        let mut meta_parts = Vec::new();
338        meta_parts.push(child.task.status.clone());
339        if child.task.priority != PRIORITY_DEFAULT {
340            meta_parts.push(format!("P{}", child.task.priority));
341        }
342        if let Some(points) = child.task.points {
343            meta_parts.push(format!("{} pts", points));
344        }
345        if let Some(ref owner) = child.task.worker_id {
346            meta_parts.push(format!("@{}", owner));
347        }
348
349        let meta_str = if !meta_parts.is_empty() {
350            format!(" [{}]", meta_parts.join(", "))
351        } else {
352            String::new()
353        };
354
355        md.push_str(&format!(
356            "{}{}{}{}\n",
357            prefix, connector, child.task.title, meta_str
358        ));
359
360        // Recursively format grandchildren
361        if !child.children.is_empty() {
362            format_tree_children(&child.children, &format!("{}{}", prefix, child_prefix), md);
363        }
364    }
365}
366
367/// Format a scan result as markdown.
368pub fn format_scan_result_markdown(result: &ScanResult) -> String {
369    let mut md = String::new();
370
371    // Root task header
372    md.push_str(&format!("# Scan: {}\\n", result.root.title));
373    md.push_str(&format!("- **id**: `{}`\\n", result.root.id));
374    md.push_str(&format!("- **status**: {}\\n", result.root.status));
375    md.push_str(&format!("- **priority**: {}\\n", result.root.priority));
376
377    if let Some(ref owner) = result.root.worker_id {
378        md.push_str(&format!("- **owner**: {}\\n", owner));
379    }
380
381    if let Some(ref desc) = result.root.description {
382        md.push_str(&format!("\\n{}\\n", desc));
383    }
384
385    // Before (predecessors)
386    if !result.before.is_empty() {
387        md.push_str(&format!("\\n## Before ({} tasks)\\n", result.before.len()));
388        md.push_str("_Tasks that block this task via blocks/follows dependencies_\\n\\n");
389        for task in &result.before {
390            md.push_str(&format_scan_task_short(task));
391        }
392    }
393
394    // After (successors)
395    if !result.after.is_empty() {
396        md.push_str(&format!("\\n## After ({} tasks)\\n", result.after.len()));
397        md.push_str("_Tasks that this task blocks via blocks/follows dependencies_\\n\\n");
398        for task in &result.after {
399            md.push_str(&format_scan_task_short(task));
400        }
401    }
402
403    // Above (ancestors)
404    if !result.above.is_empty() {
405        md.push_str(&format!("\\n## Above ({} tasks)\\n", result.above.len()));
406        md.push_str("_Parent chain via contains dependency_\\n\\n");
407        for task in &result.above {
408            md.push_str(&format_scan_task_short(task));
409        }
410    }
411
412    // Below (descendants)
413    if !result.below.is_empty() {
414        md.push_str(&format!("\\n## Below ({} tasks)\\n", result.below.len()));
415        md.push_str("_Descendants via contains dependency_\\n\\n");
416        for task in &result.below {
417            md.push_str(&format_scan_task_short(task));
418        }
419    }
420
421    // Summary
422    let total = result.before.len() + result.after.len() + result.above.len() + result.below.len();
423    md.push_str(&format!("\\n---\\n**Total related tasks**: {}\\n", total));
424
425    md
426}
427
428/// Format a task in short form for scan results.
429fn format_scan_task_short(task: &Task) -> String {
430    let priority_marker = if task.priority > 0 { "!!! " } else { "" };
431
432    let owner = task
433        .worker_id
434        .as_ref()
435        .map(|o| format!(" @{}", o))
436        .unwrap_or_default();
437
438    let points = task
439        .points
440        .map(|p| format!(" ({} pts)", p))
441        .unwrap_or_default();
442
443    format!(
444        "- {}{} `{}` [{}]{}{}\\n",
445        priority_marker,
446        task.title,
447        &task.id[..8.min(task.id.len())],
448        task.status,
449        owner,
450        points,
451    )
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::types::{PRIORITY_DEFAULT, Priority, Task, TaskTree};
458
459    fn make_test_task(
460        id: &str,
461        title: &str,
462        status: &str,
463        priority: Priority,
464        points: Option<i32>,
465    ) -> Task {
466        Task {
467            id: id.to_string(),
468            title: title.to_string(),
469            description: None,
470            status: status.to_string(),
471            phase: None,
472            priority,
473            worker_id: None,
474            claimed_at: None,
475            needed_tags: vec![],
476            wanted_tags: vec![],
477            tags: vec![],
478            points,
479            time_estimate_ms: None,
480            time_actual_ms: None,
481            started_at: None,
482            completed_at: None,
483            current_thought: None,
484            cost_usd: 0.0,
485            metrics: [0; 8],
486            created_at: 0,
487            updated_at: 0,
488        }
489    }
490
491    #[test]
492    fn test_format_task_tree_markdown_root_only() {
493        let tree = TaskTree {
494            task: make_test_task("root-1", "Root Task", "pending", 8, Some(5)),
495            children: vec![],
496        };
497
498        let result = format_task_tree_markdown(&tree);
499        assert!(result.contains("# Root Task"));
500        assert!(result.contains("PENDING"));
501        assert!(result.contains("P8"));
502        assert!(result.contains("5 pts"));
503    }
504
505    #[test]
506    fn test_format_task_tree_markdown_with_children() {
507        let tree = TaskTree {
508            task: make_test_task("root-1", "API Refactoring Sprint", "working", 8, Some(16)),
509            children: vec![
510                TaskTree {
511                    task: make_test_task("child-1", "Tier 1: Prerequisites", "pending", 8, Some(9)),
512                    children: vec![
513                        TaskTree {
514                            task: make_test_task(
515                                "grandchild-1",
516                                "Refactor connect",
517                                "completed",
518                                PRIORITY_DEFAULT,
519                                Some(3),
520                            ),
521                            children: vec![],
522                        },
523                        TaskTree {
524                            task: make_test_task(
525                                "grandchild-2",
526                                "Merge claim/release",
527                                "pending",
528                                PRIORITY_DEFAULT,
529                                Some(5),
530                            ),
531                            children: vec![],
532                        },
533                    ],
534                },
535                TaskTree {
536                    task: make_test_task(
537                        "child-2",
538                        "Tier 2: Navigation",
539                        "pending",
540                        PRIORITY_DEFAULT,
541                        Some(7),
542                    ),
543                    children: vec![],
544                },
545            ],
546        };
547
548        let result = format_task_tree_markdown(&tree);
549
550        // Check root formatting
551        assert!(result.contains("# API Refactoring Sprint"));
552        assert!(result.contains("WORKING"));
553
554        // Check tree structure characters
555        assert!(result.contains("├── Tier 1: Prerequisites"));
556        assert!(result.contains("└── Tier 2: Navigation"));
557
558        // Check grandchildren have proper indentation
559        assert!(result.contains("│   ├── Refactor connect"));
560        assert!(result.contains("│   └── Merge claim/release"));
561    }
562
563    #[test]
564    fn test_format_task_tree_markdown_deep_nesting() {
565        let tree = TaskTree {
566            task: make_test_task("root", "Root", "pending", PRIORITY_DEFAULT, None),
567            children: vec![TaskTree {
568                task: make_test_task("l1", "Level 1", "pending", PRIORITY_DEFAULT, None),
569                children: vec![TaskTree {
570                    task: make_test_task("l2", "Level 2", "pending", PRIORITY_DEFAULT, None),
571                    children: vec![TaskTree {
572                        task: make_test_task("l3", "Level 3", "pending", PRIORITY_DEFAULT, None),
573                        children: vec![],
574                    }],
575                }],
576            }],
577        };
578
579        let result = format_task_tree_markdown(&tree);
580
581        // Check deep nesting with proper prefix
582        assert!(result.contains("└── Level 1"));
583        assert!(result.contains("    └── Level 2"));
584        assert!(result.contains("        └── Level 3"));
585    }
586}