#![allow(dead_code)]
use crate::preview::mermaid::chart::{find_header, first_word, ParseError};
use crate::preview::mermaid::mindmap::{self, Dialect, Mindmap};
pub const KEYWORD: &str = "kanban";
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Card {
pub id: String,
pub label: String,
pub assigned: Option<String>,
pub ticket: Option<String>,
pub priority: Option<String>,
pub icon: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Column {
pub id: String,
pub label: String,
pub ticket: Option<String>,
pub cards: Vec<Card>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Kanban {
pub columns: Vec<Column>,
}
const DIALECT: Dialect = Dialect {
kind: KEYWORD,
single_root: false,
at_ends_the_id: true,
};
pub fn is_kanban(src: &str) -> bool {
find_header(src, &[KEYWORD]).is_some()
}
pub fn parse(src: &str) -> Result<Kanban, ParseError> {
let Some(source) = find_header(src, &[KEYWORD]) else {
return Err(ParseError::NotThisChart {
expected: KEYWORD,
header: first_word(src),
});
};
let mut meta: Vec<(String, Meta)> = Vec::new();
let mut tree = Mindmap::default();
let mut failure: Option<ParseError> = None;
mindmap::read_tree(&mut tree, &source, &DIALECT, |node, tail| {
let Some(body) = shape_data(tail) else {
return false;
};
match read_meta(body) {
Ok(m) => {
if let Some(label) = &m.label {
node.label = label.clone();
}
meta.push((node.id.clone(), m));
true
}
Err(e) => {
failure = Some(e);
true
}
}
})?;
if let Some(e) = failure {
return Err(e);
}
if tree.nodes.is_empty() {
return Err(ParseError::NoData {
kind: KEYWORD,
wanted: "column",
});
}
let section_level = 0;
let mut board = Kanban::default();
for (i, node) in tree.nodes.iter().enumerate() {
let m = meta
.iter()
.find(|(id, _)| *id == node.id)
.map(|(_, m)| m.clone())
.unwrap_or_default();
if node.level < section_level {
return Err(ParseError::Invalid {
line: 0,
message: format!("kanban card {:?} sits outside every column", node.label),
});
}
if node.level == section_level {
board.columns.push(Column {
id: id_of(node, i),
label: node.label.clone(),
ticket: m.ticket,
cards: Vec::new(),
});
continue;
}
let Some(column) = board.columns.last_mut() else {
return Err(ParseError::Invalid {
line: 0,
message: format!("kanban card {:?} sits outside every column", node.label),
});
};
column.cards.push(Card {
id: id_of(node, i),
label: node.label.clone(),
assigned: m.assigned,
ticket: m.ticket,
priority: m.priority,
icon: m.icon.or_else(|| node.icon.clone()),
});
}
Ok(board)
}
fn id_of(node: &mindmap::Node, i: usize) -> String {
if node.id.trim().is_empty() {
format!("kbn{i}")
} else {
node.id.clone()
}
}
fn shape_data(tail: &str) -> Option<&str> {
let rest = tail.trim().strip_prefix("@{")?;
Some(rest.strip_suffix('}').unwrap_or(rest))
}
#[derive(Debug, Clone, Default, PartialEq)]
struct Meta {
label: Option<String>,
assigned: Option<String>,
ticket: Option<String>,
priority: Option<String>,
icon: Option<String>,
shape: Option<String>,
}
fn read_meta(body: &str) -> Result<Meta, ParseError> {
let mut meta = Meta::default();
for field in split_fields(body) {
let field = field.trim();
if field.is_empty() {
continue;
}
let Some((key, value)) = field.split_once(':') else {
return Err(ParseError::Invalid {
line: 0,
message: format!("kanban metadata `{field}` is not `key: value`"),
});
};
let value = unquote(value.trim());
match key.trim().to_ascii_lowercase().as_str() {
"label" => meta.label = Some(value),
"assigned" => meta.assigned = Some(value),
"ticket" => meta.ticket = Some(value),
"priority" => meta.priority = Some(value),
"icon" => meta.icon = Some(value),
"shape" => meta.shape = Some(value),
other => {
return Err(ParseError::Invalid {
line: 0,
message: format!("kanban metadata has no `{other}` field"),
})
}
}
}
Ok(meta)
}
fn split_fields(body: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
for c in body.chars() {
match (quote, c) {
(Some(q), _) if c == q => {
quote = None;
current.push(c);
}
(Some(_), _) => current.push(c),
(None, '"') | (None, '\'') => {
quote = Some(c);
current.push(c);
}
(None, ',') => out.push(std::mem::take(&mut current)),
(None, '\n') => current.push(' '),
_ => current.push(c),
}
}
out.push(current);
out
}
fn unquote(v: &str) -> String {
let v = v.trim();
for q in ['"', '\''] {
if v.len() >= 2 && v.starts_with(q) && v.ends_with(q) {
return v[1..v.len() - 1].to_string();
}
}
v.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(src: &str) -> Kanban {
parse(src).unwrap_or_else(|e| panic!("{e}\n{src}"))
}
#[test]
fn a_bracketed_card_keeps_all_of_its_words() {
let b = ok("kanban\n Todo\n [Create Documentation]\n");
assert_eq!(b.columns[0].cards[0].label, "Create Documentation");
}
#[test]
fn the_documented_example_reads_as_columns_of_cards() {
let b = ok(
"kanban\n Todo\n [Create Documentation]\n docs[Create Blog about the new diagram]\n \
id7[In progress]\n id6[Create renderer so that it works in all cases]\n",
);
assert_eq!(b.columns.len(), 2);
assert_eq!(b.columns[0].label, "Todo");
assert_eq!(b.columns[0].cards.len(), 2);
assert_eq!(b.columns[1].label, "In progress");
assert_eq!(b.columns[1].id, "id7");
assert_eq!(b.columns[1].cards[0].id, "id6");
}
#[test]
fn metadata_is_read_off_the_card_it_follows() {
let b = ok(
"kanban\n Backlog\n id1[Ticket]@{ assigned: 'knsv', ticket: MC-2038, priority: 'Very High' }\n",
);
let card = &b.columns[0].cards[0];
assert_eq!(card.assigned.as_deref(), Some("knsv"));
assert_eq!(card.ticket.as_deref(), Some("MC-2038"));
assert_eq!(card.priority.as_deref(), Some("Very High"));
}
#[test]
fn metadata_label_overrides_the_bracketed_words() {
let b = ok("kanban\n Todo\n id1@{ label: 'From the metadata' }\n");
assert_eq!(b.columns[0].cards[0].label, "From the metadata");
}
#[test]
fn an_unknown_metadata_field_is_refused_rather_than_dropped() {
assert!(matches!(
parse("kanban\n Todo\n a@{ colour: red }\n"),
Err(ParseError::Invalid { .. })
));
}
#[test]
fn a_card_shallower_than_the_first_node_is_refused() {
assert!(matches!(
parse("kanban\n Todo\n Loose\n"),
Err(ParseError::Invalid { .. })
));
}
#[test]
fn two_nameless_cards_are_given_two_different_ids() {
let b = ok("kanban\n Todo\n []\n []\n");
assert_eq!(b.columns[0].cards.len(), 2);
assert_ne!(
b.columns[0].cards[0].id, b.columns[0].cards[1].id,
"two cards with no name of their own were given one id between them"
);
assert!(b.columns[0].cards[0].id.starts_with("kbn"));
}
#[test]
fn a_column_with_no_cards_is_still_a_column() {
let b = ok("kanban\n Todo\n Doing\n a\n");
assert_eq!(b.columns.len(), 2);
assert!(b.columns[0].cards.is_empty());
}
#[test]
fn a_header_with_no_columns_is_refused() {
assert!(matches!(parse("kanban\n"), Err(ParseError::NoData { .. })));
}
#[test]
fn the_routing_predicate_and_the_parser_agree() {
for src in [
"kanban\n Todo\n a",
"KANBAN\n Todo\n a",
"kanban",
"kanbans\n a",
"mindmap\n a",
"",
"%% only a comment\n",
] {
let refused = matches!(parse(src), Err(ParseError::NotThisChart { .. }));
assert_eq!(is_kanban(src), !refused, "{src:?}");
}
}
}