1use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ParsedDocument {
16 pub frontmatter: HashMap<String, serde_yaml::Value>,
18 pub body: String,
20 pub frontmatter_range: Option<(usize, usize)>,
23}
24
25pub fn parse(content: &str) -> ParsedDocument {
33 let owned;
35 let content = if content.contains("\r\n") {
36 owned = content.replace("\r\n", "\n");
37 owned.as_str()
38 } else {
39 content
40 };
41
42 if !content.starts_with("---") {
44 return ParsedDocument {
45 frontmatter: HashMap::new(),
46 body: content.to_string(),
47 frontmatter_range: None,
48 };
49 }
50
51 let after_opening = match content.find('\n') {
53 Some(pos) => pos + 1,
54 None => {
55 return ParsedDocument {
57 frontmatter: HashMap::new(),
58 body: content.to_string(),
59 frontmatter_range: None,
60 };
61 }
62 };
63
64 #[allow(clippy::string_slice)]
68 let rest = &content[after_opening..];
69 let mut offset = 0;
70 for line in rest.lines() {
71 if line.trim() == "---" {
72 let close_line_start = after_opening + offset;
74 let close_line_end = close_line_start + line.len();
75
76 let fm_end = if close_line_end < content.len()
78 && content.as_bytes()[close_line_end] == b'\n'
79 {
80 close_line_end + 1
81 } else {
82 close_line_end
83 };
84
85 #[allow(clippy::string_slice)]
91 let yaml_text = &content[after_opening..close_line_start];
92
93 let frontmatter: HashMap<String, serde_yaml::Value> =
95 match serde_yaml::from_str(yaml_text) {
96 Ok(map) => map,
97 Err(_) => {
98 return ParsedDocument {
100 frontmatter: HashMap::new(),
101 body: content.to_string(),
102 frontmatter_range: None,
103 };
104 }
105 };
106
107 #[allow(clippy::string_slice)]
110 let body = &content[fm_end..];
111
112 return ParsedDocument {
113 frontmatter,
114 body: body.to_string(),
115 frontmatter_range: Some((0, fm_end)),
116 };
117 }
118 offset += line.len() + 1; }
120
121 ParsedDocument {
123 frontmatter: HashMap::new(),
124 body: content.to_string(),
125 frontmatter_range: None,
126 }
127}
128
129pub fn serialize(
139 frontmatter: &HashMap<String, serde_yaml::Value>,
140 body: &str,
141) -> Result<String, String> {
142 if frontmatter.is_empty() {
143 return Ok(body.to_string());
144 }
145
146 let safe_fm: HashMap<String, serde_yaml::Value> = frontmatter
148 .iter()
149 .map(|(k, v)| (k.clone(), ensure_strings_quoted(v)))
150 .collect();
151
152 let yaml =
153 serde_yaml::to_string(&safe_fm).map_err(|e| format!("YAML serialize error: {}", e))?;
154
155 Ok(format!("---\n{}---\n{}", yaml, body))
157}
158
159fn ensure_strings_quoted(value: &serde_yaml::Value) -> serde_yaml::Value {
169 match value {
170 serde_yaml::Value::Sequence(seq) => {
171 serde_yaml::Value::Sequence(seq.iter().map(ensure_strings_quoted).collect())
172 }
173 serde_yaml::Value::Mapping(map) => {
174 let mut new_map = serde_yaml::Mapping::new();
175 for (k, v) in map {
176 new_map.insert(k.clone(), ensure_strings_quoted(v));
177 }
178 serde_yaml::Value::Mapping(new_map)
179 }
180 other => other.clone(),
182 }
183}
184
185pub fn value_as_string(value: &serde_yaml::Value) -> Option<String> {
191 match value {
192 serde_yaml::Value::String(s) => Some(s.clone()),
193 serde_yaml::Value::Number(n) => Some(format!("{}", n)),
194 serde_yaml::Value::Bool(b) => Some(format!("{}", b)),
195 _ => None,
196 }
197}
198
199#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn test_parse_with_frontmatter() {
209 let input = "---\ntitle: Hello World\ndate: 2024-01-15\n---\nBody content here.";
210 let doc = parse(input);
211
212 assert_eq!(doc.frontmatter.len(), 2);
213 assert_eq!(
214 doc.frontmatter.get("title").and_then(|v| v.as_str()),
215 Some("Hello World")
216 );
217 assert_eq!(
218 doc.frontmatter.get("date").and_then(|v| v.as_str()),
219 Some("2024-01-15")
220 );
221 assert_eq!(doc.body, "Body content here.");
222 assert!(doc.frontmatter_range.is_some());
223 }
224
225 #[test]
226 fn test_parse_no_frontmatter() {
227 let input = "Just body content.";
228 let doc = parse(input);
229
230 assert!(doc.frontmatter.is_empty());
231 assert_eq!(doc.body, "Just body content.");
232 assert!(doc.frontmatter_range.is_none());
233 }
234
235 #[test]
236 fn test_parse_empty_frontmatter() {
237 let input = "---\n---\nBody after empty frontmatter.";
238 let doc = parse(input);
239
240 assert_eq!(doc.body, "Body after empty frontmatter.");
245 }
246
247 #[test]
248 fn test_parse_no_closing_delimiter() {
249 let input = "---\ntitle: Hello\nno closing";
250 let doc = parse(input);
251
252 assert!(doc.frontmatter.is_empty());
253 assert_eq!(doc.body, input);
254 assert!(doc.frontmatter_range.is_none());
255 }
256
257 #[test]
258 fn test_parse_yaml_arrays() {
259 let input = "---\ntags:\n - rust\n - wasm\n---\nBody.";
260 let doc = parse(input);
261
262 let tags = doc.frontmatter.get("tags").expect("tags field");
263 let seq = tags.as_sequence().expect("should be sequence");
264 assert_eq!(seq.len(), 2);
265 assert_eq!(seq[0].as_str(), Some("rust"));
266 assert_eq!(seq[1].as_str(), Some("wasm"));
267 }
268
269 #[test]
270 fn test_parse_boolean_values() {
271 let input = "---\ndraft: true\n---\nContent.";
272 let doc = parse(input);
273
274 assert_eq!(
275 doc.frontmatter.get("draft").and_then(|v| v.as_bool()),
276 Some(true)
277 );
278 }
279
280 #[test]
281 fn test_parse_numeric_values() {
282 let input = "---\nweight: 42\nrating: 3.5\n---\nContent.";
283 let doc = parse(input);
284
285 assert_eq!(
286 doc.frontmatter.get("weight").and_then(|v| v.as_u64()),
287 Some(42)
288 );
289 assert_eq!(
290 doc.frontmatter.get("rating").and_then(|v| v.as_f64()),
291 Some(3.5)
292 );
293 }
294
295 #[test]
296 fn test_parse_preserves_body_exactly() {
297 let body = "Line 1\n\nLine 3 with **bold**\n\n- list item\n";
298 let input = format!("---\ntitle: Test\n---\n{}", body);
299 let doc = parse(&input);
300
301 assert_eq!(doc.body, body);
302 }
303
304 #[test]
305 fn test_frontmatter_range_byte_offsets() {
306 let input = "---\ntitle: Hi\n---\nBody.";
307 let doc = parse(input);
308
309 let (start, end) = doc.frontmatter_range.expect("range");
310 assert_eq!(start, 0);
311 #[allow(clippy::string_slice)] {
316 assert_eq!(&input[start..end], "---\ntitle: Hi\n---\n");
317 assert_eq!(&input[end..], "Body.");
318 }
319 }
320
321 #[test]
322 fn test_serialize_with_frontmatter() {
323 let mut fm = HashMap::new();
324 fm.insert(
325 "title".to_string(),
326 serde_yaml::Value::String("Hello".to_string()),
327 );
328
329 let result = serialize(&fm, "Body content.").expect("serialize");
330
331 assert!(result.starts_with("---\n"));
332 assert!(result.contains("title: Hello"));
333 assert!(result.contains("---\nBody content."));
334 }
335
336 #[test]
337 fn test_serialize_empty_frontmatter() {
338 let fm = HashMap::new();
339 let result = serialize(&fm, "Just body.").expect("serialize");
340 assert_eq!(result, "Just body.");
341 }
342
343 #[test]
344 fn test_parse_invalid_yaml() {
345 let input = "---\n: invalid: yaml: [unclosed\n---\nBody.";
346 let doc = parse(input);
347
348 assert!(doc.frontmatter.is_empty());
350 }
351
352 #[test]
353 fn test_parse_frontmatter_with_trailing_whitespace_on_delimiter() {
354 let input = "---\ntitle: Test\n--- \nBody.";
355 let doc = parse(input);
356
357 assert_eq!(
359 doc.frontmatter.get("title").and_then(|v| v.as_str()),
360 Some("Test")
361 );
362 assert_eq!(doc.body, "Body.");
363 }
364
365 #[test]
366 fn test_parse_content_starts_with_dashes_but_not_frontmatter() {
367 let input = "---- Not frontmatter\nJust text.";
368 let doc = parse(input);
369
370 assert!(doc.frontmatter.is_empty());
373 assert_eq!(doc.body, input);
374 }
375
376 #[test]
377 fn test_roundtrip() {
378 let input = "---\ntitle: Round Trip\n---\nBody stays the same.";
379 let doc = parse(input);
380
381 let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
382
383 let doc2 = parse(&output);
385 assert_eq!(
386 doc.frontmatter.get("title"),
387 doc2.frontmatter.get("title")
388 );
389 assert_eq!(doc.body, doc2.body);
390 }
391
392 #[test]
393 fn test_parse_multiline_body() {
394 let input = "---\ntitle: Test\n---\nParagraph 1.\n\nParagraph 2.\n\n> Quote\n";
395 let doc = parse(input);
396
397 assert_eq!(doc.body, "Paragraph 1.\n\nParagraph 2.\n\n> Quote\n");
398 }
399
400 #[test]
401 fn test_parse_only_dashes() {
402 let input = "---";
403 let doc = parse(input);
404
405 assert!(doc.frontmatter.is_empty());
406 assert_eq!(doc.body, "---");
407 }
408
409 #[test]
410 fn test_parse_crlf_content() {
411 let input = "---\r\ntitle: Hello World\r\ndate: 2024-01-15\r\n---\r\nBody content here.";
412 let doc = parse(input);
413
414 assert_eq!(doc.frontmatter.len(), 2);
415 assert_eq!(
416 doc.frontmatter.get("title").and_then(|v| v.as_str()),
417 Some("Hello World")
418 );
419 assert_eq!(
420 doc.frontmatter.get("date").and_then(|v| v.as_str()),
421 Some("2024-01-15")
422 );
423 assert_eq!(doc.body, "Body content here.");
424 assert!(doc.frontmatter_range.is_some());
425 }
426
427 #[test]
428 fn test_parse_crlf_byte_offsets() {
429 let input = "---\r\ntitle: Hi\r\n---\r\nBody.";
430 let doc = parse(input);
431
432 let (start, end) = doc.frontmatter_range.expect("range");
433 assert_eq!(start, 0);
434 assert_eq!(end, 18);
437 }
438
439 #[test]
440 fn test_parse_crlf_preserves_body() {
441 let body = "Line 1\nLine 2\n";
442 let input = format!("---\r\ntitle: Test\r\n---\r\n{}", body.replace('\n', "\r\n"));
443 let doc = parse(&input);
444
445 assert_eq!(
446 doc.frontmatter.get("title").and_then(|v| v.as_str()),
447 Some("Test")
448 );
449 assert_eq!(doc.body, body);
451 }
452
453 #[test]
454 fn test_parse_crlf_yaml_arrays() {
455 let input = "---\r\ntags:\r\n - rust\r\n - wasm\r\n---\r\nBody.";
456 let doc = parse(input);
457
458 let tags = doc.frontmatter.get("tags").expect("tags field");
459 let seq = tags.as_sequence().expect("should be sequence");
460 assert_eq!(seq.len(), 2);
461 assert_eq!(seq[0].as_str(), Some("rust"));
462 assert_eq!(seq[1].as_str(), Some("wasm"));
463 }
464
465 #[test]
466 fn test_uid_scientific_notation_roundtrip() {
467 let input = "---\ntitle: Test\nuid: \"753659e7\"\n---\nBody.";
470 let doc = parse(input);
471
472 let uid_val = doc.frontmatter.get("uid").expect("uid field");
474 assert_eq!(uid_val.as_str(), Some("753659e7"));
475
476 let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
478 let doc2 = parse(&output);
479 let uid2 = doc2.frontmatter.get("uid").expect("uid field after roundtrip");
480 assert_eq!(uid2.as_str(), Some("753659e7"));
481 }
482
483 #[test]
484 fn test_value_as_string_handles_numbers() {
485 let num_val = serde_yaml::Value::Number(serde_yaml::Number::from(75365900));
488 assert!(value_as_string(&num_val).is_some());
489
490 let str_val = serde_yaml::Value::String("753659e7".to_string());
491 assert_eq!(value_as_string(&str_val), Some("753659e7".to_string()));
492 }
493
494 #[test]
495 fn test_unquoted_uid_parsed_as_number() {
496 let input = "---\ntitle: Test\nuid: 753659e7\n---\nBody.";
498 let doc = parse(input);
499
500 let uid_val = doc.frontmatter.get("uid").expect("uid field");
501 assert!(
503 uid_val.as_str().is_none(),
504 "Unquoted 753659e7 should NOT parse as string (it's a YAML number)"
505 );
506
507 assert!(value_as_string(uid_val).is_some());
509 }
510}