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\nunlisted: false\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 assert_eq!(
279 doc.frontmatter.get("unlisted").and_then(|v| v.as_bool()),
280 Some(false)
281 );
282 }
283
284 #[test]
285 fn test_parse_numeric_values() {
286 let input = "---\nweight: 42\nrating: 3.5\n---\nContent.";
287 let doc = parse(input);
288
289 assert_eq!(
290 doc.frontmatter.get("weight").and_then(|v| v.as_u64()),
291 Some(42)
292 );
293 assert_eq!(
294 doc.frontmatter.get("rating").and_then(|v| v.as_f64()),
295 Some(3.5)
296 );
297 }
298
299 #[test]
300 fn test_parse_preserves_body_exactly() {
301 let body = "Line 1\n\nLine 3 with **bold**\n\n- list item\n";
302 let input = format!("---\ntitle: Test\n---\n{}", body);
303 let doc = parse(&input);
304
305 assert_eq!(doc.body, body);
306 }
307
308 #[test]
309 fn test_frontmatter_range_byte_offsets() {
310 let input = "---\ntitle: Hi\n---\nBody.";
311 let doc = parse(input);
312
313 let (start, end) = doc.frontmatter_range.expect("range");
314 assert_eq!(start, 0);
315 #[allow(clippy::string_slice)] {
320 assert_eq!(&input[start..end], "---\ntitle: Hi\n---\n");
321 assert_eq!(&input[end..], "Body.");
322 }
323 }
324
325 #[test]
326 fn test_serialize_with_frontmatter() {
327 let mut fm = HashMap::new();
328 fm.insert(
329 "title".to_string(),
330 serde_yaml::Value::String("Hello".to_string()),
331 );
332
333 let result = serialize(&fm, "Body content.").expect("serialize");
334
335 assert!(result.starts_with("---\n"));
336 assert!(result.contains("title: Hello"));
337 assert!(result.contains("---\nBody content."));
338 }
339
340 #[test]
341 fn test_serialize_empty_frontmatter() {
342 let fm = HashMap::new();
343 let result = serialize(&fm, "Just body.").expect("serialize");
344 assert_eq!(result, "Just body.");
345 }
346
347 #[test]
348 fn test_parse_invalid_yaml() {
349 let input = "---\n: invalid: yaml: [unclosed\n---\nBody.";
350 let doc = parse(input);
351
352 assert!(doc.frontmatter.is_empty());
354 }
355
356 #[test]
357 fn test_parse_frontmatter_with_trailing_whitespace_on_delimiter() {
358 let input = "---\ntitle: Test\n--- \nBody.";
359 let doc = parse(input);
360
361 assert_eq!(
363 doc.frontmatter.get("title").and_then(|v| v.as_str()),
364 Some("Test")
365 );
366 assert_eq!(doc.body, "Body.");
367 }
368
369 #[test]
370 fn test_parse_content_starts_with_dashes_but_not_frontmatter() {
371 let input = "---- Not frontmatter\nJust text.";
372 let doc = parse(input);
373
374 assert!(doc.frontmatter.is_empty());
377 assert_eq!(doc.body, input);
378 }
379
380 #[test]
381 fn test_roundtrip() {
382 let input = "---\ntitle: Round Trip\n---\nBody stays the same.";
383 let doc = parse(input);
384
385 let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
386
387 let doc2 = parse(&output);
389 assert_eq!(
390 doc.frontmatter.get("title"),
391 doc2.frontmatter.get("title")
392 );
393 assert_eq!(doc.body, doc2.body);
394 }
395
396 #[test]
397 fn test_parse_multiline_body() {
398 let input = "---\ntitle: Test\n---\nParagraph 1.\n\nParagraph 2.\n\n> Quote\n";
399 let doc = parse(input);
400
401 assert_eq!(doc.body, "Paragraph 1.\n\nParagraph 2.\n\n> Quote\n");
402 }
403
404 #[test]
405 fn test_parse_only_dashes() {
406 let input = "---";
407 let doc = parse(input);
408
409 assert!(doc.frontmatter.is_empty());
410 assert_eq!(doc.body, "---");
411 }
412
413 #[test]
414 fn test_parse_crlf_content() {
415 let input = "---\r\ntitle: Hello World\r\ndate: 2024-01-15\r\n---\r\nBody content here.";
416 let doc = parse(input);
417
418 assert_eq!(doc.frontmatter.len(), 2);
419 assert_eq!(
420 doc.frontmatter.get("title").and_then(|v| v.as_str()),
421 Some("Hello World")
422 );
423 assert_eq!(
424 doc.frontmatter.get("date").and_then(|v| v.as_str()),
425 Some("2024-01-15")
426 );
427 assert_eq!(doc.body, "Body content here.");
428 assert!(doc.frontmatter_range.is_some());
429 }
430
431 #[test]
432 fn test_parse_crlf_byte_offsets() {
433 let input = "---\r\ntitle: Hi\r\n---\r\nBody.";
434 let doc = parse(input);
435
436 let (start, end) = doc.frontmatter_range.expect("range");
437 assert_eq!(start, 0);
438 assert_eq!(end, 18);
441 }
442
443 #[test]
444 fn test_parse_crlf_preserves_body() {
445 let body = "Line 1\nLine 2\n";
446 let input = format!("---\r\ntitle: Test\r\n---\r\n{}", body.replace('\n', "\r\n"));
447 let doc = parse(&input);
448
449 assert_eq!(
450 doc.frontmatter.get("title").and_then(|v| v.as_str()),
451 Some("Test")
452 );
453 assert_eq!(doc.body, body);
455 }
456
457 #[test]
458 fn test_parse_crlf_yaml_arrays() {
459 let input = "---\r\ntags:\r\n - rust\r\n - wasm\r\n---\r\nBody.";
460 let doc = parse(input);
461
462 let tags = doc.frontmatter.get("tags").expect("tags field");
463 let seq = tags.as_sequence().expect("should be sequence");
464 assert_eq!(seq.len(), 2);
465 assert_eq!(seq[0].as_str(), Some("rust"));
466 assert_eq!(seq[1].as_str(), Some("wasm"));
467 }
468
469 #[test]
470 fn test_uid_scientific_notation_roundtrip() {
471 let input = "---\ntitle: Test\nuid: \"753659e7\"\n---\nBody.";
474 let doc = parse(input);
475
476 let uid_val = doc.frontmatter.get("uid").expect("uid field");
478 assert_eq!(uid_val.as_str(), Some("753659e7"));
479
480 let output = serialize(&doc.frontmatter, &doc.body).expect("serialize");
482 let doc2 = parse(&output);
483 let uid2 = doc2.frontmatter.get("uid").expect("uid field after roundtrip");
484 assert_eq!(uid2.as_str(), Some("753659e7"));
485 }
486
487 #[test]
488 fn test_value_as_string_handles_numbers() {
489 let num_val = serde_yaml::Value::Number(serde_yaml::Number::from(75365900));
492 assert!(value_as_string(&num_val).is_some());
493
494 let str_val = serde_yaml::Value::String("753659e7".to_string());
495 assert_eq!(value_as_string(&str_val), Some("753659e7".to_string()));
496 }
497
498 #[test]
499 fn test_unquoted_uid_parsed_as_number() {
500 let input = "---\ntitle: Test\nuid: 753659e7\n---\nBody.";
502 let doc = parse(input);
503
504 let uid_val = doc.frontmatter.get("uid").expect("uid field");
505 assert!(
507 uid_val.as_str().is_none(),
508 "Unquoted 753659e7 should NOT parse as string (it's a YAML number)"
509 );
510
511 assert!(value_as_string(uid_val).is_some());
513 }
514}