1mod markdown;
2mod ast;
3
4pub use self::markdown::*;
5pub use self::ast::*;
6
7use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
10pub struct ParseOptions {
11 pub strict: bool,
12 pub allow_frontmatter: bool,
13}
14
15impl Default for ParseOptions {
16 fn default() -> Self {
17 Self {
18 strict: false,
19 allow_frontmatter: true,
20 }
21 }
22}
23
24pub fn parse(content: &str) -> Result<boarddown_schema::Board, crate::Error> {
25 parse_with_options(content, ParseOptions::default())
26}
27
28pub fn parse_with_options(content: &str, options: ParseOptions) -> Result<boarddown_schema::Board, crate::Error> {
29 let normalized = content.replace("\r\n", "\n");
30 let (frontmatter, body) = if options.allow_frontmatter {
31 extract_frontmatter(&normalized)?
32 } else {
33 (None, normalized.as_str())
34 };
35
36 let mut board = parse_body(body, &options)?;
37
38 if let Some(fm) = frontmatter {
39 apply_frontmatter(&mut board, fm)?;
40 }
41
42 Ok(board)
43}
44
45fn extract_frontmatter(content: &str) -> Result<(Option<serde_yaml::Value>, &str), crate::Error> {
46 if !content.starts_with("---\n") {
47 return Ok((None, content));
48 }
49
50 let end_marker = content[4..].find("\n---\n");
51 if let Some(end_pos) = end_marker {
52 let yaml_content = &content[4..end_pos + 4];
53 let body_start = end_pos + 9;
54 let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_content)?;
55 Ok((Some(yaml), &content[body_start..]))
56 } else {
57 Ok((None, content))
58 }
59}
60
61fn apply_frontmatter(board: &mut boarddown_schema::Board, fm: serde_yaml::Value) -> Result<(), crate::Error> {
62 if let serde_yaml::Value::Mapping(map) = fm {
63 if let Some(serde_yaml::Value::String(title)) = map.get(&serde_yaml::Value::String("board".to_string())) {
64 board.title = title.clone();
65 }
66 if let Some(serde_yaml::Value::String(prefix)) = map.get(&serde_yaml::Value::String("id-prefix".to_string())) {
67 board.metadata.id_prefix = Some(prefix.clone());
68 }
69 if let Some(serde_yaml::Value::String(id)) = map.get(&serde_yaml::Value::String("id".to_string())) {
70 board.id = boarddown_schema::BoardId(id.clone());
71 }
72 }
73 Ok(())
74}
75
76fn parse_body(content: &str, options: &ParseOptions) -> Result<boarddown_schema::Board, crate::Error> {
77 use boarddown_schema::{Board, BoardId, Column, ColumnRef, Task, TaskId, Metadata};
78
79 let mut columns: Vec<Column> = Vec::new();
80 let mut tasks: HashMap<TaskId, Task> = HashMap::new();
81 let mut current_column = "Todo".to_string();
82 let mut task_counter: u64 = 0;
83
84 let mut pending_metadata: HashMap<String, String> = HashMap::new();
85 let mut current_task_id: Option<TaskId> = None;
86
87 for line in content.lines() {
88 let trimmed = line.trim();
89
90 if let Some(heading) = parse_heading(trimmed) {
91 current_column = heading.clone();
92 if !columns.iter().any(|c| c.name == heading) {
93 columns.push(Column::new(&heading));
94 }
95 continue;
96 }
97
98 if let Some(task_match) = parse_task_line(trimmed) {
99 if let Some(tid) = ¤t_task_id {
100 apply_metadata_to_task(&mut tasks, tid, &pending_metadata);
101 }
102 pending_metadata.clear();
103
104 let (id_str, title, status) = task_match;
105
106 let task_id = if let Some(id_str) = id_str {
107 if options.strict {
108 TaskId::parse(&id_str).map_err(crate::Error::Parse)?
109 } else {
110 TaskId::from(id_str)
111 }
112 } else {
113 task_counter += 1;
114 TaskId::new("TASK", task_counter)
115 };
116
117 let task = Task {
118 id: task_id.clone(),
119 title,
120 status,
121 column: ColumnRef::Name(current_column.clone()),
122 metadata: Metadata::default(),
123 dependencies: Vec::new(),
124 references: Vec::new(),
125 created_at: chrono::Utc::now(),
126 updated_at: chrono::Utc::now(),
127 };
128
129 tasks.insert(task_id.clone(), task);
130 current_task_id = Some(task_id);
131 } else if trimmed.starts_with("Tags:") || trimmed.starts_with("Assign:") ||
132 trimmed.starts_with("Depends:") || trimmed.starts_with("Estimate:") ||
133 trimmed.starts_with("Priority:") || trimmed.starts_with("Due:") ||
134 trimmed.starts_with("Started:") || trimmed.starts_with("Closed:") ||
135 trimmed.starts_with("Branch:") || trimmed.starts_with("PR:") ||
136 trimmed.starts_with("Epic:") || trimmed.starts_with("Refs:") {
137 if let Some((key, value)) = trimmed.split_once(':') {
138 pending_metadata.insert(key.trim().to_string(), value.trim().to_string());
139 }
140 } else if let Some((key, value)) = trimmed.split_once(':') {
141 if !key.is_empty() && current_task_id.is_some() {
142 pending_metadata.insert(key.trim().to_string(), value.trim().to_string());
143 }
144 }
145 }
146
147 if let Some(tid) = ¤t_task_id {
148 apply_metadata_to_task(&mut tasks, tid, &pending_metadata);
149 }
150
151 if columns.is_empty() {
152 columns.push(Column::new("Todo"));
153 }
154
155 Ok(Board {
156 id: BoardId::from(format!("board-{}", chrono::Utc::now().timestamp())),
157 title: String::new(),
158 columns,
159 tasks,
160 metadata: Default::default(),
161 created_at: chrono::Utc::now(),
162 updated_at: chrono::Utc::now(),
163 })
164}
165
166fn apply_metadata_to_task(
167 tasks: &mut std::collections::HashMap<boarddown_schema::TaskId, boarddown_schema::Task>,
168 task_id: &boarddown_schema::TaskId,
169 metadata: &HashMap<String, String>,
170) {
171 if let Some(task) = tasks.get_mut(task_id) {
172 for (key, value) in metadata {
173 match key.as_str() {
174 "Tags" => {
175 task.metadata.tags = value.split(',')
176 .map(|s| s.trim().to_string())
177 .filter(|s| !s.is_empty())
178 .collect();
179 }
180 "Assign" => {
181 task.metadata.assign = Some(value.clone());
182 }
183 "Estimate" => {
184 task.metadata.estimate = value.parse().ok();
185 }
186 "Priority" => {
187 task.metadata.priority = value.parse().ok();
188 }
189 "Due" => {
190 task.metadata.due = chrono::DateTime::parse_from_rfc3339(value)
191 .ok()
192 .map(|dt| dt.with_timezone(&chrono::Utc));
193 }
194 "Started" => {
195 task.metadata.started = chrono::DateTime::parse_from_rfc3339(value)
196 .ok()
197 .map(|dt| dt.with_timezone(&chrono::Utc));
198 }
199 "Closed" => {
200 task.metadata.closed = chrono::DateTime::parse_from_rfc3339(value)
201 .ok()
202 .map(|dt| dt.with_timezone(&chrono::Utc));
203 }
204 "Depends" => {
205 task.dependencies = value.split(',')
206 .map(|s| s.trim())
207 .filter(|s| !s.is_empty())
208 .map(|s| {
209 if s.starts_with('{') && s.ends_with('}') {
210 &s[1..s.len()-1]
211 } else {
212 s
213 }
214 })
215 .map(|s| boarddown_schema::TaskId::from(s))
216 .collect();
217 }
218 "Refs" => {
219 task.references = value.split(',')
220 .map(|s| s.trim())
221 .filter(|s| !s.is_empty())
222 .map(|s| {
223 if s.starts_with('{') && s.ends_with('}') {
224 &s[1..s.len()-1]
225 } else {
226 s
227 }
228 })
229 .map(|s| boarddown_schema::TaskId::from(s))
230 .collect();
231 }
232 "Branch" => {
233 task.metadata.branch = Some(value.clone());
234 }
235 "PR" => {
236 task.metadata.pr = Some(value.clone());
237 }
238 "Epic" => {
239 task.metadata.epic = Some(value.clone());
240 }
241 _ => {
242 task.metadata.custom.insert(key.clone(), serde_json::Value::String(value.clone()));
243 }
244 }
245 }
246 }
247}
248
249fn parse_heading(line: &str) -> Option<String> {
250 if line.starts_with("## ") {
251 let heading = line[3..].trim();
252 let name = heading.trim_matches('"').trim();
253 Some(name.to_string())
254 } else {
255 None
256 }
257}
258
259fn parse_task_line(line: &str) -> Option<(Option<String>, String, boarddown_schema::Status)> {
260 let task_pattern = "- [";
261 if !line.starts_with(task_pattern) {
262 return None;
263 }
264
265 if line.len() < 6 || line.chars().nth(4)? != ']' {
266 return None;
267 }
268
269 let status_char = line.chars().nth(3)?;
270 let status = match status_char {
271 ' ' => boarddown_schema::Status::Todo,
272 '~' => boarddown_schema::Status::InProgress,
273 '>' => boarddown_schema::Status::Ready,
274 'x' => boarddown_schema::Status::Done,
275 '?' => boarddown_schema::Status::Blocked,
276 '!' => boarddown_schema::Status::Urgent,
277 _ => return None,
278 };
279
280 let rest = line[6..].trim();
281
282 let (id, title) = if rest.starts_with('{') {
283 if let Some(close_brace) = rest.find('}') {
284 let id_part = rest[1..close_brace].trim();
285 let title_part = rest[close_brace + 1..].trim();
286 let title_part = title_part.trim_start_matches(':').trim();
287 (Some(id_part.to_string()), title_part.to_string())
288 } else {
289 (None, rest.to_string())
290 }
291 } else if let Some(colon_pos) = rest.find(':') {
292 let potential_id = rest[..colon_pos].trim();
293 if potential_id.contains('-') &&
294 potential_id.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) &&
295 potential_id.split('-').nth(1).map(|s| s.chars().all(|c| c.is_ascii_digit())).unwrap_or(false) {
296 (Some(potential_id.to_string()), rest[colon_pos + 1..].trim().to_string())
297 } else {
298 (None, rest.to_string())
299 }
300 } else {
301 (None, rest.to_string())
302 };
303
304 Some((id, title, status))
305}
306
307pub fn serialize(board: &boarddown_schema::Board) -> Result<String, crate::Error> {
308 let mut output = String::new();
309
310 let has_frontmatter = !board.title.is_empty()
311 || board.metadata.id_prefix.is_some()
312 || board.id.0 != format!("board-{}", board.created_at.timestamp());
313
314 if has_frontmatter {
315 output.push_str("---\n");
316 output.push_str(&format!("id: \"{}\"\n", board.id.0));
317 if !board.title.is_empty() {
318 output.push_str(&format!("board: \"{}\"\n", board.title));
319 }
320 if let Some(prefix) = &board.metadata.id_prefix {
321 output.push_str(&format!("id-prefix: \"{}\"\n", prefix));
322 }
323 output.push_str("---\n\n");
324 }
325
326 if !board.title.is_empty() {
327 output.push_str(&format!("# {}\n\n", board.title));
328 }
329
330 for column in &board.columns {
331 output.push_str(&format!("## {}\n\n", column.name));
332
333 let column_tasks: Vec<_> = board.tasks.values()
334 .filter(|t| matches!(&t.column, boarddown_schema::ColumnRef::Name(name) if name == &column.name))
335 .collect();
336
337 for task in column_tasks {
338 let status_marker = match task.status {
339 boarddown_schema::Status::Todo => " ",
340 boarddown_schema::Status::InProgress => "~",
341 boarddown_schema::Status::Ready => ">",
342 boarddown_schema::Status::Done => "x",
343 boarddown_schema::Status::Blocked => "?",
344 boarddown_schema::Status::Urgent => "!",
345 };
346
347 output.push_str(&format!("- [{}] {{{}}}: {}\n", status_marker, task.id, task.title));
348
349 if !task.metadata.tags.is_empty() {
350 output.push_str(&format!(" Tags: {}\n", task.metadata.tags.join(", ")));
351 }
352 if let Some(assign) = &task.metadata.assign {
353 output.push_str(&format!(" Assign: {}\n", assign));
354 }
355 if let Some(estimate) = task.metadata.estimate {
356 output.push_str(&format!(" Estimate: {}\n", estimate));
357 }
358 if !task.dependencies.is_empty() {
359 let deps: Vec<_> = task.dependencies.iter().map(|d| format!("{{{}}}", d)).collect();
360 output.push_str(&format!(" Depends: {}\n", deps.join(", ")));
361 }
362 }
363
364 output.push('\n');
365 }
366
367 Ok(output)
368}