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