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/// Maximum title length in list/scan markdown output before truncation.
134pub const MAX_TITLE_DISPLAY_LEN: usize = 80;
135
136/// Truncate a title for display in list/scan output.
137/// Titles longer than MAX_TITLE_DISPLAY_LEN are cut at the limit and "..." is appended.
138/// Multi-line titles are collapsed to the first line.
139pub fn truncate_title(title: &str) -> std::borrow::Cow<'_, str> {
140    // Collapse to first line
141    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        // Truncate at char boundary
146        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
151/// Format a state name for display (capitalize, replace underscores with spaces).
152fn 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
166/// Priority marker for list/scan output. Only shows for above-default priorities.
167fn priority_marker(priority: i32) -> &'static str {
168    match priority {
169        10 => "!!! ",
170        8..=9 => "!! ",
171        6..=7 => "! ",
172        _ => "",
173    }
174}
175
176/// Format a task in short form for lists.
177fn 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
209/// Format workers as markdown.
210pub 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
242/// Format attachments as markdown.
243pub 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        // Use type as header, with name in parentheses if present
255        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        // Format created_at as relative time if possible
276        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
285/// Convert markdown to JSON value for uniform response handling.
286pub fn markdown_to_json(md: String) -> Value {
287    serde_json::json!({
288        "format": "markdown",
289        "content": md
290    })
291}
292
293/// Result type for tool handlers - allows returning either JSON or raw text.
294#[derive(Debug)]
295pub enum ToolResult {
296    /// JSON value (will be serialized to JSON string)
297    Json(Value),
298    /// Raw text (returned as-is, typically markdown)
299    Raw(String),
300}
301
302impl ToolResult {
303    /// Create a JSON result
304    pub fn json(value: Value) -> Self {
305        ToolResult::Json(value)
306    }
307
308    /// Create a raw text result (for markdown)
309    pub fn raw(text: String) -> Self {
310        ToolResult::Raw(text)
311    }
312
313    /// Convert to the appropriate string representation
314    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
322/// Format a task tree as markdown with visual tree structure.
323pub fn format_task_tree_markdown(tree: &TaskTree) -> String {
324    let mut md = String::new();
325
326    // Format root task as heading
327    md.push_str(&format!("# {}\n", tree.task.title));
328
329    // Add root task metadata
330    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    // Format children with tree characters
351    if !tree.children.is_empty() {
352        md.push('\n');
353        format_tree_children(&tree.children, "", &mut md);
354    }
355
356    md
357}
358
359/// Recursively format children with tree structure characters.
360fn 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        // Build the task line with metadata
369        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        // Recursively format grandchildren
393        if !child.children.is_empty() {
394            format_tree_children(&child.children, &format!("{}{}", prefix, child_prefix), md);
395        }
396    }
397}
398
399/// Format a scan result as markdown.
400pub fn format_scan_result_markdown(result: &ScanResult) -> String {
401    let mut md = String::new();
402
403    // Root task header
404    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    // Before (predecessors)
418    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    // After (successors)
427    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    // Above (ancestors)
436    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    // Below (descendants)
445    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    // Summary
454    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
460/// Format a task in short form for scan results.
461fn 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        // Check root formatting
583        assert!(result.contains("# API Refactoring Sprint"));
584        assert!(result.contains("WORKING"));
585
586        // Check tree structure characters
587        assert!(result.contains("├── Tier 1: Prerequisites"));
588        assert!(result.contains("└── Tier 2: Navigation"));
589
590        // Check grandchildren have proper indentation
591        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        // Check deep nesting with proper prefix
614        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}