Skip to main content

boarddown_core/parser/
mod.rs

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) = &current_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) = &current_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}