boarddown_core/parser/
markdown.rs1use super::{BoardAst, Section, MetaSection, ColumnSection, TaskNode};
2use crate::Error;
3use boarddown_schema::{Board, Status, TaskId};
4
5pub fn parse_markdown(content: &str) -> Result<BoardAst, Error> {
6 let normalized = content.replace("\r\n", "\n");
7 let (frontmatter, body) = extract_frontmatter(&normalized)?;
8
9 let mut sections: Vec<Section> = Vec::new();
10 let mut tasks: Vec<TaskNode> = Vec::new();
11 let mut current_column = "Todo".to_string();
12 let mut column_order = 0;
13 let mut line_number = 0;
14
15 for line in body.lines() {
16 line_number += 1;
17 let trimmed = line.trim();
18
19 if let Some(heading) = parse_heading(trimmed) {
20 current_column = heading.clone();
21 if !sections.iter().any(|s| matches!(s, Section::Column(c) if c.name == heading)) {
22 sections.push(Section::Column(ColumnSection {
23 name: heading,
24 order: column_order,
25 }));
26 column_order += 1;
27 }
28 continue;
29 }
30
31 if let Some(task_node) = parse_task_line_with_position(trimmed, line_number) {
32 let mut task = task_node;
33 task.indent = line.chars().take_while(|c| *c == ' ' || *c == '\t').count();
34 tasks.push(task);
35 }
36 }
37
38 if sections.is_empty() {
39 sections.push(Section::Column(ColumnSection {
40 name: "Todo".to_string(),
41 order: 0,
42 }));
43 }
44
45 Ok(BoardAst {
46 frontmatter,
47 sections,
48 tasks,
49 })
50}
51
52pub fn parse_task_line(line: &str) -> Option<TaskNode> {
53 parse_task_line_with_position(line, 0)
54}
55
56fn parse_task_line_with_position(line: &str, line_number: usize) -> Option<TaskNode> {
57 let task_pattern = "- [";
58 if !line.starts_with(task_pattern) {
59 return None;
60 }
61
62 if line.len() < 6 || line.chars().nth(4)? != ']' {
63 return None;
64 }
65
66 let status_char = line.chars().nth(3)?;
67 let status = match status_char {
68 ' ' => Status::Todo,
69 '~' => Status::InProgress,
70 '>' => Status::Ready,
71 'x' => Status::Done,
72 '?' => Status::Blocked,
73 '!' => Status::Urgent,
74 _ => return None,
75 };
76
77 let rest = line[6..].trim();
78
79 let (id, title) = if rest.starts_with('{') {
80 if let Some(close_brace) = rest.find('}') {
81 let id_part = rest[1..close_brace].trim();
82 let title_part = rest[close_brace + 1..].trim();
83 let title_part = title_part.trim_start_matches(':').trim();
84 let task_id = TaskId::from(id_part);
85 (Some(task_id), title_part.to_string())
86 } else {
87 (None, rest.to_string())
88 }
89 } else if let Some(colon_pos) = rest.find(':') {
90 let potential_id = rest[..colon_pos].trim();
91 if potential_id.contains('-') &&
92 potential_id.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) &&
93 potential_id.split('-').nth(1).map(|s| s.chars().all(|c| c.is_ascii_digit())).unwrap_or(false) {
94 let task_id = TaskId::from(potential_id);
95 (Some(task_id), rest[colon_pos + 1..].trim().to_string())
96 } else {
97 (None, rest.to_string())
98 }
99 } else {
100 (None, rest.to_string())
101 };
102
103 Some(TaskNode {
104 id,
105 title,
106 status,
107 metadata: String::new(),
108 indent: 0,
109 line_number,
110 })
111}
112
113pub fn extract_frontmatter(content: &str) -> Result<(Option<serde_yaml::Value>, &str), Error> {
114 if !content.starts_with("---\n") {
115 return Ok((None, content));
116 }
117
118 let content_after_first_marker = &content[4..];
119 if let Some(end_pos) = content_after_first_marker.find("\n---\n") {
120 let yaml_content = &content[4..end_pos + 4];
121 let body_start = end_pos + 9;
122 let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_content)?;
123 Ok((Some(yaml), &content[body_start..]))
124 } else if let Some(end_pos) = content_after_first_marker.find("\n---") {
125 if end_pos + 8 <= content.len() {
126 let yaml_content = &content[4..end_pos + 4];
127 let body_start = end_pos + 8;
128 let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_content)?;
129 Ok((Some(yaml), &content[body_start..]))
130 } else {
131 Ok((None, content))
132 }
133 } else {
134 Ok((None, content))
135 }
136}
137
138pub fn serialize_board(board: &Board) -> Result<String, Error> {
139 let mut output = String::new();
140
141 let has_frontmatter = !board.title.is_empty()
142 || board.metadata.id_prefix.is_some()
143 || board.id.0 != format!("board-{}", board.created_at.timestamp());
144
145 if has_frontmatter {
146 output.push_str("---\n");
147 output.push_str(&format!("id: \"{}\"\n", board.id.0));
148 if !board.title.is_empty() {
149 output.push_str(&format!("board: \"{}\"\n", board.title));
150 }
151 if let Some(prefix) = &board.metadata.id_prefix {
152 output.push_str(&format!("id-prefix: \"{}\"\n", prefix));
153 }
154 output.push_str("---\n\n");
155 }
156
157 if !board.title.is_empty() {
158 output.push_str(&format!("# {}\n\n", board.title));
159 }
160
161 for column in &board.columns {
162 output.push_str(&format!("## {}\n\n", column.name));
163
164 let column_tasks: Vec<_> = board.tasks.values()
165 .filter(|t| matches!(&t.column, boarddown_schema::ColumnRef::Name(name) if name == &column.name))
166 .collect();
167
168 for task in column_tasks {
169 let status_marker = match task.status {
170 Status::Todo => " ",
171 Status::InProgress => "~",
172 Status::Ready => ">",
173 Status::Done => "x",
174 Status::Blocked => "?",
175 Status::Urgent => "!",
176 };
177
178 output.push_str(&format!("- [{}] {{{}}}: {}\n", status_marker, task.id, task.title));
179
180 if !task.metadata.tags.is_empty() {
181 output.push_str(&format!(" Tags: {}\n", task.metadata.tags.join(", ")));
182 }
183 if let Some(assign) = &task.metadata.assign {
184 output.push_str(&format!(" Assign: {}\n", assign));
185 }
186 if let Some(estimate) = task.metadata.estimate {
187 output.push_str(&format!(" Estimate: {}\n", estimate));
188 }
189 if let Some(priority) = task.metadata.priority {
190 output.push_str(&format!(" Priority: {:?}\n", priority));
191 }
192 if let Some(due) = &task.metadata.due {
193 output.push_str(&format!(" Due: {}\n", due.to_rfc3339()));
194 }
195 if let Some(branch) = &task.metadata.branch {
196 output.push_str(&format!(" Branch: {}\n", branch));
197 }
198 if let Some(pr) = &task.metadata.pr {
199 output.push_str(&format!(" PR: {}\n", pr));
200 }
201 if let Some(epic) = &task.metadata.epic {
202 output.push_str(&format!(" Epic: {}\n", epic));
203 }
204 if !task.dependencies.is_empty() {
205 let deps: Vec<_> = task.dependencies.iter().map(|d| format!("{{{}}}", d)).collect();
206 output.push_str(&format!(" Depends: {}\n", deps.join(", ")));
207 }
208 if !task.references.is_empty() {
209 let refs: Vec<_> = task.references.iter().map(|r| format!("{{{}}}", r)).collect();
210 output.push_str(&format!(" Refs: {}\n", refs.join(", ")));
211 }
212 }
213
214 output.push('\n');
215 }
216
217 Ok(output)
218}
219
220fn parse_heading(line: &str) -> Option<String> {
221 if line.starts_with("## ") {
222 let heading = line[3..].trim();
223 let name = heading.trim_matches('"').trim();
224 Some(name.to_string())
225 } else {
226 None
227 }
228}