1use std::collections::{HashMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 document::AnnotationRef,
9 document::SourceSpan,
10 errors::{Diagnostic, McdError, Result},
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct DirectiveParseOptions {
16 pub strict: bool,
18}
19
20impl Default for DirectiveParseOptions {
21 fn default() -> Self {
22 Self { strict: true }
23 }
24}
25
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum TableDisplay {
30 #[default]
32 Table,
33 Chart,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct TablePlacement {
41 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
43 pub ref_id: Option<String>,
44 pub table: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub view: Option<String>,
49 #[serde(default)]
51 pub display: TableDisplay,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub caption: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub numbering: Option<String>,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub annotations: Vec<AnnotationRef>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct ImagePlacement {
67 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
69 pub ref_id: Option<String>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub asset: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub image: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub caption: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub alt: Option<String>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub annotations: Vec<AnnotationRef>,
85}
86
87pub fn parse_table_directive(
89 body: &str,
90 source: Option<SourceSpan>,
91 options: DirectiveParseOptions,
92) -> Result<TablePlacement> {
93 let fields = parse_fields(
94 body,
95 &[
96 "ref",
97 "table",
98 "view",
99 "display",
100 "caption",
101 "numbering",
102 "annotation",
103 "annotations",
104 ],
105 source,
106 options,
107 )?;
108
109 let table = required_field(&fields, "table", "directive.table.table.missing", source)?;
110 let display = match fields.get("display").map(String::as_str).unwrap_or("table") {
111 "table" => TableDisplay::Table,
112 "chart" => TableDisplay::Chart,
113 value => {
114 return Err(directive_error(
115 "directive.table.display.invalid",
116 format!("Table directive display must be 'table' or 'chart', got '{value}'."),
117 source,
118 ));
119 }
120 };
121
122 if display == TableDisplay::Chart && empty_or_missing(fields.get("view")) {
123 return Err(directive_error(
124 "directive.table.view.required",
125 "Table directives with display: chart must include view.",
126 source,
127 ));
128 }
129
130 Ok(TablePlacement {
131 ref_id: optional_field(&fields, "ref"),
132 table,
133 view: optional_field(&fields, "view"),
134 display,
135 caption: optional_field(&fields, "caption"),
136 numbering: optional_field(&fields, "numbering"),
137 annotations: directive_annotation_refs(&fields),
138 })
139}
140
141pub fn parse_image_directive(
143 body: &str,
144 source: Option<SourceSpan>,
145 options: DirectiveParseOptions,
146) -> Result<ImagePlacement> {
147 let fields = parse_fields(
148 body,
149 &[
150 "ref",
151 "asset",
152 "image",
153 "caption",
154 "alt",
155 "annotation",
156 "annotations",
157 ],
158 source,
159 options,
160 )?;
161
162 let asset = optional_field(&fields, "asset");
163 let image = optional_field(&fields, "image");
164 if asset.is_none() && image.is_none() {
165 return Err(directive_error(
166 "directive.image.asset.missing",
167 "Image directive must include asset or image.",
168 source,
169 ));
170 }
171
172 Ok(ImagePlacement {
173 ref_id: optional_field(&fields, "ref"),
174 asset,
175 image,
176 caption: optional_field(&fields, "caption"),
177 alt: optional_field(&fields, "alt"),
178 annotations: directive_annotation_refs(&fields),
179 })
180}
181
182fn directive_annotation_refs(fields: &HashMap<String, String>) -> Vec<AnnotationRef> {
183 fields
184 .get("annotations")
185 .or_else(|| fields.get("annotation"))
186 .map(|value| {
187 value
188 .split(',')
189 .map(str::trim)
190 .filter(|id| !id.is_empty())
191 .map(|id| AnnotationRef {
192 id: id.to_owned(),
193 text_offset: None,
194 })
195 .collect()
196 })
197 .unwrap_or_default()
198}
199
200fn parse_fields(
201 body: &str,
202 known_fields: &[&str],
203 source: Option<SourceSpan>,
204 options: DirectiveParseOptions,
205) -> Result<HashMap<String, String>> {
206 let known = known_fields.iter().copied().collect::<HashSet<_>>();
207 let mut fields = HashMap::new();
208
209 for (index, line) in body.lines().enumerate() {
210 let trimmed = line.trim();
211 if trimmed.is_empty() {
212 continue;
213 }
214
215 let Some((key, value)) = trimmed.split_once(':') else {
216 return Err(directive_error(
217 "directive.syntax.invalid",
218 format!(
219 "Directive field on body line {} must use 'key: value' syntax.",
220 index + 1
221 ),
222 source,
223 ));
224 };
225 let key = key.trim();
226 if key.is_empty() || key.contains(char::is_whitespace) {
227 return Err(directive_error(
228 "directive.syntax.invalid",
229 format!(
230 "Directive field on body line {} has an invalid key.",
231 index + 1
232 ),
233 source,
234 ));
235 }
236 if options.strict && !known.contains(key) {
237 return Err(directive_error(
238 "directive.field.unknown",
239 format!("Unknown directive field '{key}'."),
240 source,
241 ));
242 }
243 if fields
244 .insert(key.to_string(), value.trim().to_string())
245 .is_some()
246 {
247 return Err(directive_error(
248 "directive.field.duplicate",
249 format!("Duplicate directive field '{key}'."),
250 source,
251 ));
252 }
253 }
254
255 Ok(fields)
256}
257
258fn required_field(
259 fields: &HashMap<String, String>,
260 name: &'static str,
261 code: &'static str,
262 source: Option<SourceSpan>,
263) -> Result<String> {
264 optional_field(fields, name).ok_or_else(|| {
265 directive_error(
266 code,
267 format!("Directive field '{name}' is required."),
268 source,
269 )
270 })
271}
272
273fn optional_field(fields: &HashMap<String, String>, name: &str) -> Option<String> {
274 fields
275 .get(name)
276 .map(|value| value.trim())
277 .filter(|value| !value.is_empty())
278 .map(ToOwned::to_owned)
279}
280
281fn empty_or_missing(value: Option<&String>) -> bool {
282 value.is_none_or(|value| value.trim().is_empty())
283}
284
285fn directive_error(
286 code: impl Into<String>,
287 message: impl Into<String>,
288 source: Option<SourceSpan>,
289) -> McdError {
290 let diagnostic = match source {
291 Some(source) => Diagnostic::error(code, message).with_source(source.to_string()),
292 None => Diagnostic::error(code, message),
293 };
294 McdError::from_diagnostic(diagnostic)
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn table_display_defaults_to_table() {
303 let placement = parse_table_directive(
304 "ref: revenue-table\ntable: revenue\ncaption: Revenue",
305 None,
306 DirectiveParseOptions::default(),
307 )
308 .expect("directive parses");
309
310 assert_eq!(placement.display, TableDisplay::Table);
311 assert_eq!(placement.table, "revenue");
312 }
313
314 #[test]
315 fn chart_requires_view() {
316 let err = parse_table_directive(
317 "table: revenue\ndisplay: chart",
318 None,
319 DirectiveParseOptions::default(),
320 )
321 .expect_err("chart without view should fail");
322
323 assert_eq!(
324 err.diagnostic().map(|d| d.code.as_str()),
325 Some("directive.table.view.required")
326 );
327 }
328
329 #[test]
330 fn image_requires_asset_or_metadata_ref() {
331 let err = parse_image_directive(
332 "ref: process-diagram",
333 None,
334 DirectiveParseOptions::default(),
335 )
336 .expect_err("image without asset should fail");
337
338 assert_eq!(
339 err.diagnostic().map(|d| d.code.as_str()),
340 Some("directive.image.asset.missing")
341 );
342 }
343}