use crate::config::StatesConfig;
use crate::types::{ScanResult, Task, TaskTree, WorkerInfo, PRIORITY_DEFAULT};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Json,
Markdown,
}
impl OutputFormat {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"json" => Some(OutputFormat::Json),
"markdown" | "md" => Some(OutputFormat::Markdown),
_ => None,
}
}
}
pub fn format_task_markdown(task: &Task, blocked_by: &[String]) -> String {
let mut md = String::new();
md.push_str(&format!("## Task: {}\n", task.title));
md.push_str(&format!("- **id**: `{}`\n", task.id));
md.push_str(&format!("- **status**: {}\n", task.status));
md.push_str(&format!("- **priority**: {}\n", task.priority));
if let Some(ref owner) = task.worker_id {
md.push_str(&format!("- **owner**: {}\n", owner));
}
if !blocked_by.is_empty() {
let blockers: Vec<String> = blocked_by.iter().map(|id| format!("`{}`", id)).collect();
md.push_str(&format!("- **blocked_by**: {}\n", blockers.join(", ")));
}
if let Some(points) = task.points {
md.push_str(&format!("- **points**: {}\n", points));
}
if let Some(ref thought) = task.current_thought {
md.push_str(&format!("- **thought**: {}\n", thought));
}
if let Some(ref desc) = task.description {
md.push_str("\n### Description\n");
md.push_str(desc);
md.push('\n');
}
md
}
pub fn format_tasks_markdown(
tasks: &[(Task, Vec<String>)],
states_config: &StatesConfig,
) -> String {
let mut md = String::new();
md.push_str(&format!("# Tasks ({})\n\n", tasks.len()));
let mut by_status: HashMap<String, Vec<&(Task, Vec<String>)>> = HashMap::new();
for state in states_config.state_names() {
by_status.insert(state.to_string(), Vec::new());
}
for task_entry in tasks {
by_status
.entry(task_entry.0.status.clone())
.or_default()
.push(task_entry);
}
for state in &states_config.blocking_states {
if state != &states_config.initial {
if let Some(state_tasks) = by_status.get(state) {
if !state_tasks.is_empty() {
md.push_str(&format!("## {}\n\n", format_state_name(state)));
for (task, blocked_by) in state_tasks {
md.push_str(&format_task_short(task, blocked_by));
}
md.push('\n');
}
}
}
}
if let Some(state_tasks) = by_status.get(&states_config.initial) {
if !state_tasks.is_empty() {
md.push_str(&format!(
"## {}\n\n",
format_state_name(&states_config.initial)
));
for (task, blocked_by) in state_tasks {
md.push_str(&format_task_short(task, blocked_by));
}
md.push('\n');
}
}
for state in states_config.state_names() {
if !states_config.is_blocking_state(state) && state != &states_config.initial {
if let Some(state_tasks) = by_status.get(state) {
if !state_tasks.is_empty() {
md.push_str(&format!("## {}\n\n", format_state_name(state)));
for (task, blocked_by) in state_tasks {
md.push_str(&format_task_short(task, blocked_by));
}
md.push('\n');
}
}
}
}
md
}
fn format_state_name(state: &str) -> String {
state
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn format_task_short(task: &Task, blocked_by: &[String]) -> String {
let priority_marker = if task.priority > 0 {
"!!! "
} else {
""
};
let blocked = if blocked_by.is_empty() {
String::new()
} else {
format!(" [blocked by {}]", blocked_by.len())
};
let owner = task.worker_id.as_ref()
.map(|o| format!(" @{}", o))
.unwrap_or_default();
let thought = task.current_thought.as_ref()
.map(|t| format!(" - _{}_", t))
.unwrap_or_default();
format!(
"- {}{} `{}`{}{}{}\n",
priority_marker,
task.title,
&task.id[..8.min(task.id.len())],
owner,
blocked,
thought,
)
}
pub fn format_workers_markdown(workers: &[WorkerInfo]) -> String {
let mut md = String::new();
md.push_str(&format!("# Workers ({})\n\n", workers.len()));
for worker in workers {
md.push_str(&format!("## {}\n", worker.id));
md.push_str(&format!("- **id**: `{}`\n", worker.id));
if !worker.tags.is_empty() {
md.push_str(&format!("- **tags**: {}\n", worker.tags.join(", ")));
}
md.push_str(&format!("- **claims**: {}/{}\n", worker.claim_count, worker.max_claims));
if let Some(ref thought) = worker.current_thought {
md.push_str(&format!("- **doing**: {}\n", thought));
}
md.push('\n');
}
md
}
pub fn format_attachments_markdown(attachments: &[crate::types::AttachmentMeta]) -> String {
let mut md = String::new();
md.push_str(&format!("# Attachments ({})\n\n", attachments.len()));
if attachments.is_empty() {
md.push_str("_No attachments found._\n");
return md;
}
for attachment in attachments {
md.push_str(&format!("## {}\n", attachment.name));
md.push_str(&format!("- **index**: {}\n", attachment.order_index));
md.push_str(&format!("- **mime**: {}\n", attachment.mime_type));
if let Some(ref fp) = attachment.file_path {
md.push_str(&format!("- **file**: `{}`\n", fp));
}
let created_secs = attachment.created_at / 1000;
md.push_str(&format!("- **created**: {}\n", created_secs));
md.push('\n');
}
md
}
pub fn markdown_to_json(md: String) -> Value {
serde_json::json!({
"format": "markdown",
"content": md
})
}
#[derive(Debug)]
pub enum ToolResult {
Json(Value),
Raw(String),
}
impl ToolResult {
pub fn json(value: Value) -> Self {
ToolResult::Json(value)
}
pub fn raw(text: String) -> Self {
ToolResult::Raw(text)
}
pub fn into_string(self) -> String {
match self {
ToolResult::Json(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
ToolResult::Raw(s) => s,
}
}
}
pub fn format_task_tree_markdown(tree: &TaskTree) -> String {
let mut md = String::new();
md.push_str(&format!("# {}\n", tree.task.title));
let mut meta_parts = Vec::new();
meta_parts.push(tree.task.status.to_uppercase());
if tree.task.priority != PRIORITY_DEFAULT {
meta_parts.push(format!("P{}", tree.task.priority));
}
if let Some(points) = tree.task.points {
meta_parts.push(format!("{} pts", points));
}
if let Some(ref owner) = tree.task.worker_id {
meta_parts.push(format!("@{}", owner));
}
if !meta_parts.is_empty() {
md.push_str(&format!("_{}_\n", meta_parts.join(", ")));
}
if let Some(ref desc) = tree.task.description {
md.push_str(&format!("\n{}\n", desc));
}
if !tree.children.is_empty() {
md.push('\n');
format_tree_children(&tree.children, "", &mut md);
}
md
}
fn format_tree_children(children: &[TaskTree], prefix: &str, md: &mut String) {
let count = children.len();
for (i, child) in children.iter().enumerate() {
let is_last = i == count - 1;
let connector = if is_last { "└── " } else { "├── " };
let child_prefix = if is_last { " " } else { "│ " };
let mut meta_parts = Vec::new();
meta_parts.push(child.task.status.clone());
if child.task.priority != PRIORITY_DEFAULT {
meta_parts.push(format!("P{}", child.task.priority));
}
if let Some(points) = child.task.points {
meta_parts.push(format!("{} pts", points));
}
if let Some(ref owner) = child.task.worker_id {
meta_parts.push(format!("@{}", owner));
}
let meta_str = if !meta_parts.is_empty() {
format!(" [{}]", meta_parts.join(", "))
} else {
String::new()
};
md.push_str(&format!("{}{}{}{}\n", prefix, connector, child.task.title, meta_str));
if !child.children.is_empty() {
format_tree_children(&child.children, &format!("{}{}", prefix, child_prefix), md);
}
}
}
pub fn format_scan_result_markdown(result: &ScanResult) -> String {
let mut md = String::new();
md.push_str(&format!("# Scan: {}\\n", result.root.title));
md.push_str(&format!("- **id**: `{}`\\n", result.root.id));
md.push_str(&format!("- **status**: {}\\n", result.root.status));
md.push_str(&format!("- **priority**: {}\\n", result.root.priority));
if let Some(ref owner) = result.root.worker_id {
md.push_str(&format!("- **owner**: {}\\n", owner));
}
if let Some(ref desc) = result.root.description {
md.push_str(&format!("\\n{}\\n", desc));
}
if !result.before.is_empty() {
md.push_str(&format!("\\n## Before ({} tasks)\\n", result.before.len()));
md.push_str("_Tasks that block this task via blocks/follows dependencies_\\n\\n");
for task in &result.before {
md.push_str(&format_scan_task_short(task));
}
}
if !result.after.is_empty() {
md.push_str(&format!("\\n## After ({} tasks)\\n", result.after.len()));
md.push_str("_Tasks that this task blocks via blocks/follows dependencies_\\n\\n");
for task in &result.after {
md.push_str(&format_scan_task_short(task));
}
}
if !result.above.is_empty() {
md.push_str(&format!("\\n## Above ({} tasks)\\n", result.above.len()));
md.push_str("_Parent chain via contains dependency_\\n\\n");
for task in &result.above {
md.push_str(&format_scan_task_short(task));
}
}
if !result.below.is_empty() {
md.push_str(&format!("\\n## Below ({} tasks)\\n", result.below.len()));
md.push_str("_Descendants via contains dependency_\\n\\n");
for task in &result.below {
md.push_str(&format_scan_task_short(task));
}
}
let total = result.before.len() + result.after.len() + result.above.len() + result.below.len();
md.push_str(&format!("\\n---\\n**Total related tasks**: {}\\n", total));
md
}
fn format_scan_task_short(task: &Task) -> String {
let priority_marker = if task.priority > 0 {
"!!! "
} else {
""
};
let owner = task.worker_id.as_ref()
.map(|o| format!(" @{}", o))
.unwrap_or_default();
let points = task.points
.map(|p| format!(" ({} pts)", p))
.unwrap_or_default();
format!(
"- {}{} `{}` [{}]{}{}\\n",
priority_marker,
task.title,
&task.id[..8.min(task.id.len())],
task.status,
owner,
points,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Priority, Task, TaskTree, PRIORITY_DEFAULT};
fn make_test_task(id: &str, title: &str, status: &str, priority: Priority, points: Option<i32>) -> Task {
Task {
id: id.to_string(),
title: title.to_string(),
description: None,
status: status.to_string(),
priority,
worker_id: None,
claimed_at: None,
needed_tags: vec![],
wanted_tags: vec![],
tags: vec![],
points,
time_estimate_ms: None,
time_actual_ms: None,
started_at: None,
completed_at: None,
current_thought: None,
cost_usd: 0.0,
metrics: [0; 8],
created_at: 0,
updated_at: 0,
}
}
#[test]
fn test_format_task_tree_markdown_root_only() {
let tree = TaskTree {
task: make_test_task("root-1", "Root Task", "pending", 8, Some(5)),
children: vec![],
};
let result = format_task_tree_markdown(&tree);
assert!(result.contains("# Root Task"));
assert!(result.contains("PENDING"));
assert!(result.contains("P8"));
assert!(result.contains("5 pts"));
}
#[test]
fn test_format_task_tree_markdown_with_children() {
let tree = TaskTree {
task: make_test_task("root-1", "API Refactoring Sprint", "in_progress", 8, Some(16)),
children: vec![
TaskTree {
task: make_test_task("child-1", "Tier 1: Prerequisites", "pending", 8, Some(9)),
children: vec![
TaskTree {
task: make_test_task("grandchild-1", "Refactor connect", "completed", PRIORITY_DEFAULT, Some(3)),
children: vec![],
},
TaskTree {
task: make_test_task("grandchild-2", "Merge claim/release", "pending", PRIORITY_DEFAULT, Some(5)),
children: vec![],
},
],
},
TaskTree {
task: make_test_task("child-2", "Tier 2: Navigation", "pending", PRIORITY_DEFAULT, Some(7)),
children: vec![],
},
],
};
let result = format_task_tree_markdown(&tree);
assert!(result.contains("# API Refactoring Sprint"));
assert!(result.contains("IN_PROGRESS"));
assert!(result.contains("├── Tier 1: Prerequisites"));
assert!(result.contains("└── Tier 2: Navigation"));
assert!(result.contains("│ ├── Refactor connect"));
assert!(result.contains("│ └── Merge claim/release"));
}
#[test]
fn test_format_task_tree_markdown_deep_nesting() {
let tree = TaskTree {
task: make_test_task("root", "Root", "pending", PRIORITY_DEFAULT, None),
children: vec![
TaskTree {
task: make_test_task("l1", "Level 1", "pending", PRIORITY_DEFAULT, None),
children: vec![
TaskTree {
task: make_test_task("l2", "Level 2", "pending", PRIORITY_DEFAULT, None),
children: vec![
TaskTree {
task: make_test_task("l3", "Level 3", "pending", PRIORITY_DEFAULT, None),
children: vec![],
},
],
},
],
},
],
};
let result = format_task_tree_markdown(&tree);
assert!(result.contains("└── Level 1"));
assert!(result.contains(" └── Level 2"));
assert!(result.contains(" └── Level 3"));
}
}