Skip to main content

boarddown_core/parser/
markdown.rs

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