use serde_yaml::Value;
use crate::parse::{extract_wiki_links, parse_frontmatter, split_frontmatter};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Kind {
Epic,
Story,
Task,
}
impl Kind {
fn from_str(s: &str) -> Option<Kind> {
match s {
"epic" => Some(Kind::Epic),
"story" => Some(Kind::Story),
"task" => Some(Kind::Task),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct ParsedTicket {
pub id: String,
pub kind: Option<Kind>,
pub status: String,
pub parent: Option<String>,
pub title: String,
pub depends_on: Vec<String>,
#[allow(dead_code)]
pub aliases: Vec<String>,
pub links: Vec<String>,
pub raw: String,
}
pub fn parse_ticket(blob: &str) -> ParsedTicket {
let split = split_frontmatter(blob);
let front = parse_frontmatter(&split.fm);
let get_str = |key: &str| -> String {
front
.as_ref()
.and_then(|v| v.get(key))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default()
};
let id = get_str("id");
let kind = {
let ks = get_str("kind");
Kind::from_str(&ks)
};
let status = get_str("status");
if status.is_empty() {
}
let status = if status.is_empty() {
"todo".to_string()
} else {
status
};
let parent = front.as_ref().and_then(|v| v.get("parent")).and_then(|v| {
if v.is_null() {
None
} else {
v.as_str().map(|s| s.to_string())
}
});
let title = get_str("title");
let depends_on = extract_list(&front, "depends_on");
let aliases = extract_list(&front, "aliases");
let links = extract_wiki_links(&split.body);
ParsedTicket {
id,
kind,
status,
parent,
title,
depends_on,
aliases,
links,
raw: split.body,
}
}
fn extract_list(front: &Option<Value>, key: &str) -> Vec<String> {
let val = match front.as_ref().and_then(|v| v.get(key)) {
Some(v) => v,
None => return Vec::new(),
};
match val {
Value::Sequence(seq) => seq
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
Value::String(s) if !s.trim().is_empty() => vec![s.trim().to_string()],
Value::String(_) => Vec::new(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_canonical() {
let blob = "---\nid: http-connect-proxy\naliases: [http-connect-proxy]\nkind: task\nparent: network-firewall\ntitle: HTTP CONNECT allowlist proxy\nstatus: review\nassignee: null\ncreated: 2026-08-01\nupdated: 2026-08-01\ntags: []\ndepends_on: [parse-foundation, cli-scaffolding]\n---\n\n## Goal\n\nImplement the proxy.\n";
let t = parse_ticket(blob);
assert_eq!(t.id, "http-connect-proxy");
assert_eq!(t.kind, Some(Kind::Task));
assert_eq!(t.status, "review");
assert_eq!(t.parent.as_deref(), Some("network-firewall"));
assert_eq!(t.title, "HTTP CONNECT allowlist proxy");
assert_eq!(t.depends_on, vec!["parse-foundation", "cli-scaffolding"]);
assert_eq!(t.aliases, vec!["http-connect-proxy"]);
assert!(t.raw.contains("## Goal"));
}
#[test]
fn test_parse_block_depends_on() {
let blob = "---\nid: test\nkind: task\nparent: story\nstatus: todo\ntitle: Test\ndepends_on:\n - a\n - b\n---\n";
let t = parse_ticket(blob);
assert_eq!(t.depends_on, vec!["a", "b"]);
}
#[test]
fn test_parse_single_dep_as_string() {
let blob = "---\nid: test\nkind: task\nparent: story\nstatus: todo\ntitle: Test\ndepends_on: some-task\n---\n";
let t = parse_ticket(blob);
assert_eq!(t.depends_on, vec!["some-task"]);
}
#[test]
fn test_parse_no_deps() {
let blob = "---\nid: test\nkind: task\nparent: story\nstatus: todo\ntitle: Test\ndepends_on: []\n---\n";
let t = parse_ticket(blob);
let empty: Vec<String> = Vec::new();
assert_eq!(t.depends_on, empty);
}
#[test]
fn test_parse_absent_deps() {
let blob = "---\nid: test\nkind: task\nparent: story\nstatus: todo\ntitle: Test\n---\n";
let t = parse_ticket(blob);
let empty: Vec<String> = Vec::new();
assert_eq!(t.depends_on, empty);
}
#[test]
fn test_parse_absent_parent() {
let blob = "---\nid: test\nkind: epic\nstatus: todo\ntitle: Test\n---\n";
let t = parse_ticket(blob);
assert_eq!(t.parent, None);
}
#[test]
fn test_parse_null_parent() {
let blob = "---\nid: test\nkind: task\nparent:\nstatus: todo\ntitle: Test\n---\n";
let t = parse_ticket(blob);
assert_eq!(t.parent, None);
}
#[test]
fn test_parse_missing_status_defaults_todo() {
let blob = "---\nid: test\nkind: task\nparent: story\ntitle: Test\n---\n";
let t = parse_ticket(blob);
assert_eq!(t.status, "todo");
}
#[test]
fn test_parse_quoted_status() {
let blob = "---\nid: test\nkind: task\nparent: story\nstatus: \"done\"\ntitle: Test\n---\n";
let t = parse_ticket(blob);
assert_eq!(t.status, "done");
}
#[test]
fn test_parse_wiki_links_from_body() {
let blob = "---\nid: test\nkind: task\nparent: story\nstatus: todo\ntitle: Test\n---\n\nSee [[other-task]].\n";
let t = parse_ticket(blob);
assert_eq!(t.links, vec!["other-task"]);
}
#[test]
fn test_parse_obsidian_reformatted() {
let blob = "---\nid: http-connect-proxy\naliases:\n - http-connect-proxy\nkind: task\nparent: network-firewall\ntitle: HTTP CONNECT allowlist proxy\nstatus: \"done\"\nassignee: null\ncreated: 2026-08-01\nupdated: 2026-08-01\ntags: []\ndepends_on:\n - parse-foundation\n - cli-scaffolding\n---\n\n## Goal\n\nObsidian-reformatted.\n";
let t = parse_ticket(blob);
assert_eq!(t.id, "http-connect-proxy");
assert_eq!(t.status, "done");
assert_eq!(t.depends_on, vec!["parse-foundation", "cli-scaffolding"]);
assert_eq!(t.aliases, vec!["http-connect-proxy"]);
}
}