Skip to main content

boarddown_core/parser/
markdown.rs

1use 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}