boarddown_core/parser/
ast.rs1use boarddown_schema::{Board, BoardId, Column, ColumnRef, Status, Task, TaskId, Metadata, BoardMetadata};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone)]
5pub struct BoardAst {
6 pub frontmatter: Option<serde_yaml::Value>,
7 pub sections: Vec<Section>,
8 pub tasks: Vec<TaskNode>,
9}
10
11#[derive(Debug, Clone)]
12pub enum Section {
13 Meta(MetaSection),
14 Column(ColumnSection),
15 Custom { heading: String, content: String },
16}
17
18#[derive(Debug, Clone)]
19pub struct MetaSection {
20 pub fields: Vec<(String, String)>,
21}
22
23#[derive(Debug, Clone)]
24pub struct ColumnSection {
25 pub name: String,
26 pub order: usize,
27}
28
29#[derive(Debug, Clone)]
30pub struct TaskNode {
31 pub id: Option<TaskId>,
32 pub title: String,
33 pub status: Status,
34 pub metadata: String,
35 pub indent: usize,
36 pub line_number: usize,
37}
38
39impl BoardAst {
40 pub fn into_board(self, id: BoardId) -> Result<Board, crate::Error> {
41 let mut columns: Vec<Column> = Vec::new();
42 let mut tasks: HashMap<TaskId, Task> = HashMap::new();
43 let mut current_column = "Todo".to_string();
44 let mut task_counter: u64 = 0;
45 let mut board_metadata = BoardMetadata::default();
46 let mut title = String::new();
47
48 if let Some(ref fm) = self.frontmatter {
49 if let serde_yaml::Value::Mapping(map) = fm {
50 if let Some(serde_yaml::Value::String(t)) = map.get(&serde_yaml::Value::String("board".to_string())) {
51 title = t.clone();
52 }
53 if let Some(serde_yaml::Value::String(prefix)) = map.get(&serde_yaml::Value::String("id-prefix".to_string())) {
54 board_metadata.id_prefix = Some(prefix.clone());
55 }
56 }
57 }
58
59 for section in &self.sections {
60 if let Section::Column(col_section) = section {
61 if !columns.iter().any(|c| c.name == col_section.name) {
62 columns.push(Column::new(&col_section.name));
63 }
64 }
65 }
66
67 let mut prev_task_id: Option<TaskId> = None;
68 let mut pending_metadata: HashMap<String, String> = HashMap::new();
69
70 for task_node in self.tasks {
71 if let Some(ref prev_id) = prev_task_id {
72 apply_metadata_to_task(&mut tasks, prev_id, &pending_metadata);
73 }
74 pending_metadata.clear();
75
76 let column = current_column.clone();
77 let task_id = task_node.id.unwrap_or_else(|| {
78 task_counter += 1;
79 let prefix = board_metadata.id_prefix.as_deref().unwrap_or("TASK");
80 TaskId::new(prefix, task_counter)
81 });
82
83 current_column = column.clone();
84
85 let task = Task {
86 id: task_id.clone(),
87 title: task_node.title,
88 status: task_node.status,
89 column: ColumnRef::Name(column),
90 metadata: Metadata::default(),
91 dependencies: Vec::new(),
92 references: Vec::new(),
93 created_at: chrono::Utc::now(),
94 updated_at: chrono::Utc::now(),
95 };
96
97 tasks.insert(task_id.clone(), task);
98 prev_task_id = Some(task_id);
99 }
100
101 if let Some(ref prev_id) = prev_task_id {
102 apply_metadata_to_task(&mut tasks, prev_id, &pending_metadata);
103 }
104
105 if columns.is_empty() {
106 columns.push(Column::new("Todo"));
107 }
108
109 Ok(Board {
110 id,
111 title,
112 columns,
113 tasks,
114 metadata: board_metadata,
115 created_at: chrono::Utc::now(),
116 updated_at: chrono::Utc::now(),
117 })
118 }
119}
120
121fn apply_metadata_to_task(
122 tasks: &mut HashMap<TaskId, Task>,
123 task_id: &TaskId,
124 metadata: &HashMap<String, String>,
125) {
126 if let Some(task) = tasks.get_mut(task_id) {
127 for (key, value) in metadata {
128 match key.as_str() {
129 "Tags" => {
130 task.metadata.tags = value.split(',')
131 .map(|s| s.trim().to_string())
132 .filter(|s| !s.is_empty())
133 .collect();
134 }
135 "Assign" => {
136 task.metadata.assign = Some(value.clone());
137 }
138 "Estimate" => {
139 task.metadata.estimate = value.parse().ok();
140 }
141 "Priority" => {
142 task.metadata.priority = value.parse().ok();
143 }
144 "Due" => {
145 task.metadata.due = chrono::DateTime::parse_from_rfc3339(value)
146 .ok()
147 .map(|dt| dt.with_timezone(&chrono::Utc));
148 }
149 "Started" => {
150 task.metadata.started = chrono::DateTime::parse_from_rfc3339(value)
151 .ok()
152 .map(|dt| dt.with_timezone(&chrono::Utc));
153 }
154 "Closed" => {
155 task.metadata.closed = chrono::DateTime::parse_from_rfc3339(value)
156 .ok()
157 .map(|dt| dt.with_timezone(&chrono::Utc));
158 }
159 "Depends" => {
160 task.dependencies = value.split(',')
161 .map(|s| s.trim())
162 .filter(|s| !s.is_empty())
163 .map(|s| {
164 if s.starts_with('{') && s.ends_with('}') {
165 &s[1..s.len()-1]
166 } else {
167 s
168 }
169 })
170 .map(|s| TaskId::from(s))
171 .collect();
172 }
173 "Refs" => {
174 task.references = value.split(',')
175 .map(|s| s.trim())
176 .filter(|s| !s.is_empty())
177 .map(|s| {
178 if s.starts_with('{') && s.ends_with('}') {
179 &s[1..s.len()-1]
180 } else {
181 s
182 }
183 })
184 .map(|s| TaskId::from(s))
185 .collect();
186 }
187 "Branch" => {
188 task.metadata.branch = Some(value.clone());
189 }
190 "PR" => {
191 task.metadata.pr = Some(value.clone());
192 }
193 "Epic" => {
194 task.metadata.epic = Some(value.clone());
195 }
196 _ => {
197 task.metadata.custom.insert(key.clone(), serde_json::Value::String(value.clone()));
198 }
199 }
200 }
201 }
202}