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