#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeShape {
Default, RoundedRect, Rect, Circle, Cloud, Bang, Hexagon, }
#[derive(Debug, Clone)]
pub struct KanbanSection {
pub id: String,
pub label: String,
pub items: Vec<KanbanItem>,
}
#[derive(Debug, Clone)]
pub struct KanbanItem {
pub id: String,
pub label: String,
pub shape: NodeShape,
pub priority: Option<String>,
pub ticket: Option<String>,
pub assigned: Option<String>,
}
pub struct KanbanConfig {
pub ticket_base_url: Option<String>,
}
pub struct KanbanDiagram {
pub sections: Vec<KanbanSection>,
pub config: KanbanConfig,
}
pub fn parse(input: &str) -> crate::error::ParseResult<KanbanDiagram> {
let mut sections: Vec<KanbanSection> = Vec::new();
let ticket_base_url = extract_ticket_base_url(input);
let body = strip_frontmatter(input);
let mut header_seen = false;
let mut base_indent: Option<usize> = None;
let mut current_section: Option<KanbanSection> = None;
let mut item_counter: usize = 0;
let mut section_counter: usize = 0;
for raw_line in body.lines() {
let trimmed_end = raw_line.trim_end();
let trimmed = trimmed_end.trim();
if trimmed.is_empty() || trimmed.starts_with("%%") {
continue;
}
if !header_seen {
if trimmed.eq_ignore_ascii_case("kanban") {
header_seen = true;
}
continue;
}
if trimmed.to_lowercase().starts_with("title ") {
continue;
}
let indent = raw_line.len() - raw_line.trim_start().len();
if base_indent.is_none() {
base_indent = Some(indent);
}
let base = base_indent.unwrap_or(0);
let relative_level = if indent >= base {
(indent - base) / 2
} else {
0
};
if relative_level == 0 {
if let Some(sec) = current_section.take() {
sections.push(sec);
}
let (id, label) = parse_node_id_and_label(trimmed, &mut section_counter);
section_counter += 1;
current_section = Some(KanbanSection {
id,
label,
items: Vec::new(),
});
} else {
let (id, label, shape, priority, ticket, assigned) =
parse_item(trimmed, &mut item_counter);
item_counter += 1;
let item = KanbanItem {
id,
label,
shape,
priority,
ticket,
assigned,
};
if let Some(ref mut sec) = current_section {
sec.items.push(item);
}
}
}
if let Some(sec) = current_section.take() {
sections.push(sec);
}
crate::error::ParseResult::ok(KanbanDiagram {
sections,
config: KanbanConfig { ticket_base_url },
})
}
fn strip_frontmatter(input: &str) -> &str {
let trimmed = input.trim_start();
if !trimmed.starts_with("---") {
return input;
}
let after_open = &trimmed[3..];
if let Some(close_pos) = after_open.find("\n---") {
let after_close = &after_open[close_pos + 4..];
return after_close.trim_start_matches('\n');
}
input
}
fn parse_node_id_and_label(content: &str, counter: &mut usize) -> (String, String) {
if let Some(bracket_pos) = content.find('[') {
if content.ends_with(']') {
let id = content[..bracket_pos].trim().to_string();
let inner = &content[bracket_pos + 1..content.len() - 1];
let label = inner
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string();
let id = if id.is_empty() {
format!("section_{}", counter)
} else {
id
};
return (id, label);
}
}
let id = content.trim().to_string();
let label = id.clone();
(id, label)
}
fn parse_item(
content: &str,
counter: &mut usize,
) -> (
String,
String,
NodeShape,
Option<String>,
Option<String>,
Option<String>,
) {
let (content_no_meta, priority, ticket, assigned) = if let Some(at_pos) = content.find("@{") {
let meta = content[at_pos..].trim();
let priority = extract_meta_value(meta, "priority");
let ticket = extract_meta_value(meta, "ticket");
let assigned = extract_meta_value(meta, "assigned");
(content[..at_pos].trim_end(), priority, ticket, assigned)
} else {
(content, None, None, None)
};
let (id, label, shape) = parse_item_content(content_no_meta.trim(), counter);
(id, label, shape, priority, ticket, assigned)
}
fn extract_meta_value(meta: &str, key: &str) -> Option<String> {
let search = format!("{key}:");
let pos = meta.find(&search)?;
let rest = meta[pos + search.len()..].trim_start();
let value = rest.trim_start_matches('\'').trim_start_matches('"');
let end = value.find(['\'', '"', ',', '}']).unwrap_or(value.len());
Some(value[..end].trim().to_string())
}
fn extract_ticket_base_url(input: &str) -> Option<String> {
let trimmed = input.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let end = trimmed.find("\n---")?;
let frontmatter = &trimmed[3..end];
let pos = frontmatter.find("ticketBaseUrl")?;
let rest = frontmatter[pos + "ticketBaseUrl".len()..]
.trim_start_matches(':')
.trim_start();
let value = rest.trim_start_matches('\'').trim_start_matches('"');
let quote_end = value.find(['\'', '"', '\n']).unwrap_or(value.len());
Some(value[..quote_end].trim().to_string())
}
fn parse_item_content(content: &str, counter: &mut usize) -> (String, String, NodeShape) {
if let Some(pos) = content.find("((") {
if content.ends_with("))") && content.len() > pos + 4 {
let id = content[..pos].trim().to_string();
let label = content[pos + 2..content.len() - 2]
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string();
let id = if id.is_empty() {
format!("item_{counter}")
} else {
id
};
return (id, label, NodeShape::Circle);
}
}
if let Some(pos) = content.find("{{") {
if content.ends_with("}}") && content.len() > pos + 4 {
let id = content[..pos].trim().to_string();
let label = content[pos + 2..content.len() - 2]
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string();
let id = if id.is_empty() {
format!("item_{counter}")
} else {
id
};
return (id, label, NodeShape::Hexagon);
}
}
if let Some(pos) = content.find("))") {
if content.ends_with("((") && content.len() > pos + 4 {
let id = content[..pos].trim().to_string();
let label = content[pos + 2..content.len() - 2]
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string();
let id = if id.is_empty() {
format!("item_{counter}")
} else {
id
};
return (id, label, NodeShape::Bang);
}
}
if let Some(pos) = content.find('[') {
if content.ends_with(']') && content.len() > pos + 2 {
let id = content[..pos].trim().to_string();
let label = content[pos + 1..content.len() - 1]
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string();
let id = if id.is_empty() {
format!("item_{counter}")
} else {
id
};
return (id, label, NodeShape::Rect);
}
}
if let Some(pos) = find_single_open_paren(content) {
if content.ends_with(')') && !content.ends_with("))") && content.len() > pos + 2 {
let id = content[..pos].trim().to_string();
let label = content[pos + 1..content.len() - 1]
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string();
let id = if id.is_empty() {
format!("item_{counter}")
} else {
id
};
return (id, label, NodeShape::RoundedRect);
}
}
if let Some(pos) = content.find(')') {
if content.ends_with('(') && !content.ends_with("((") && content.len() > pos + 2 {
let id = content[..pos].trim().to_string();
let label = content[pos + 1..content.len() - 1]
.trim_matches('"')
.trim_matches('\'')
.trim()
.to_string();
let id = if id.is_empty() {
format!("item_{counter}")
} else {
id
};
return (id, label, NodeShape::Cloud);
}
}
let id = content.to_string();
let label = id.clone();
(id, label, NodeShape::Default)
}
fn find_single_open_paren(content: &str) -> Option<usize> {
let bytes = content.as_bytes();
(0..bytes.len()).find(|&i| bytes[i] == b'(' && bytes.get(i + 1).copied() != Some(b'('))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_kanban() {
let input = "kanban\n todo\n id1[Task 1]\n id2[Task 2]\n inProgress\n id3[Task 3]\n done\n id4[Task 4]";
let d = parse(input).diagram;
assert_eq!(d.sections.len(), 3);
assert_eq!(d.sections[0].id, "todo");
assert_eq!(d.sections[0].label, "todo");
assert_eq!(d.sections[0].items.len(), 2);
assert_eq!(d.sections[0].items[0].label, "Task 1");
assert_eq!(d.sections[0].items[1].label, "Task 2");
assert_eq!(d.sections[1].id, "inProgress");
assert_eq!(d.sections[1].items[0].label, "Task 3");
assert_eq!(d.sections[2].id, "done");
assert_eq!(d.sections[2].items[0].label, "Task 4");
}
#[test]
fn section_with_bracket_label() {
let input = "kanban\n col1[\"To Do\"]\n item1[\"Task A\"]\n";
let d = parse(input).diagram;
assert_eq!(d.sections[0].id, "col1");
assert_eq!(d.sections[0].label, "To Do");
assert_eq!(d.sections[0].items[0].label, "Task A");
}
#[test]
fn item_shapes() {
let input = "kanban\n col\n a[Rect]\n b(Round)\n c((Circle))\n";
let d = parse(input).diagram;
assert_eq!(d.sections[0].items[0].shape, NodeShape::Rect);
assert_eq!(d.sections[0].items[1].shape, NodeShape::RoundedRect);
assert_eq!(d.sections[0].items[2].shape, NodeShape::Circle);
}
#[test]
fn yaml_metadata() {
let input = "kanban\n col\n id1[Task]@{ ticket: MC-1, priority: High }\n";
let d = parse(input).diagram;
let item = &d.sections[0].items[0];
assert_eq!(item.label, "Task");
assert_eq!(item.shape, NodeShape::Rect);
}
#[test]
fn frontmatter_stripped() {
let input =
"---\nconfig:\n kanban:\n sectionWidth: 150\n---\nkanban\n col\n id1[Task]\n";
let d = parse(input).diagram;
assert_eq!(d.sections.len(), 1);
assert_eq!(d.sections[0].items[0].label, "Task");
}
}