1use indexmap::IndexMap;
2use nu_cmd_base::formats::to::delimited::merge_descriptors;
3use nu_engine::command_prelude::*;
4use nu_protocol::{Config, ast::PathMember};
5use std::collections::HashSet;
6
7#[derive(Clone)]
8pub struct ToMd;
9
10impl Command for ToMd {
11 fn name(&self) -> &str {
12 "to md"
13 }
14
15 fn signature(&self) -> Signature {
16 Signature::build("to md")
17 .input_output_types(vec![(Type::Any, Type::String)])
18 .switch(
19 "pretty",
20 "Formats the Markdown table to vertically align items",
21 Some('p'),
22 )
23 .switch(
24 "per-element",
25 "treat each row as markdown syntax element",
26 Some('e'),
27 )
28 .named(
29 "center",
30 SyntaxShape::List(Box::new(SyntaxShape::CellPath)),
31 "Formats the Markdown table to center given columns",
32 Some('c'),
33 )
34 .category(Category::Formats)
35 }
36
37 fn description(&self) -> &str {
38 "Convert table into simple Markdown."
39 }
40
41 fn examples(&self) -> Vec<Example> {
42 vec![
43 Example {
44 description: "Outputs an MD string representing the contents of this table",
45 example: "[[foo bar]; [1 2]] | to md",
46 result: Some(Value::test_string("|foo|bar|\n|-|-|\n|1|2|")),
47 },
48 Example {
49 description: "Optionally, output a formatted markdown string",
50 example: "[[foo bar]; [1 2]] | to md --pretty",
51 result: Some(Value::test_string(
52 "| foo | bar |\n| --- | --- |\n| 1 | 2 |",
53 )),
54 },
55 Example {
56 description: "Treat each row as a markdown element",
57 example: r#"[{"H1": "Welcome to Nushell" } [[foo bar]; [1 2]]] | to md --per-element --pretty"#,
58 result: Some(Value::test_string(
59 "# Welcome to Nushell\n| foo | bar |\n| --- | --- |\n| 1 | 2 |",
60 )),
61 },
62 Example {
63 description: "Render a list",
64 example: "[0 1 2] | to md --pretty",
65 result: Some(Value::test_string("0\n1\n2")),
66 },
67 Example {
68 description: "Separate list into markdown tables",
69 example: "[ {foo: 1, bar: 2} {foo: 3, bar: 4} {foo: 5}] | to md --per-element",
70 result: Some(Value::test_string(
71 "|foo|bar|\n|-|-|\n|1|2|\n|3|4|\n|foo|\n|-|\n|5|",
72 )),
73 },
74 Example {
75 description: "Center a column of a markdown table",
76 example: "[ {foo: 1, bar: 2} {foo: 3, bar: 4}] | to md --pretty --center [bar]",
77 result: Some(Value::test_string(
78 "| foo | bar |\n| --- |:---:|\n| 1 | 2 |\n| 3 | 4 |",
79 )),
80 },
81 ]
82 }
83
84 fn run(
85 &self,
86 engine_state: &EngineState,
87 stack: &mut Stack,
88 call: &Call,
89 input: PipelineData,
90 ) -> Result<PipelineData, ShellError> {
91 let head = call.head;
92 let pretty = call.has_flag(engine_state, stack, "pretty")?;
93 let per_element = call.has_flag(engine_state, stack, "per-element")?;
94 let center: Option<Vec<CellPath>> = call.get_flag(engine_state, stack, "center")?;
95 let config = stack.get_config(engine_state);
96 to_md(input, pretty, per_element, ¢er, &config, head)
97 }
98}
99
100fn to_md(
101 input: PipelineData,
102 pretty: bool,
103 per_element: bool,
104 center: &Option<Vec<CellPath>>,
105 config: &Config,
106 head: Span,
107) -> Result<PipelineData, ShellError> {
108 let metadata = input
110 .metadata()
111 .unwrap_or_default()
112 .with_content_type(Some("text/markdown".into()));
113
114 let (grouped_input, single_list) = group_by(input, head, config);
115 if per_element || single_list {
116 return Ok(Value::string(
117 grouped_input
118 .into_iter()
119 .map(move |val| match val {
120 Value::List { .. } => {
121 format!(
122 "{}\n",
123 table(val.into_pipeline_data(), pretty, center, config)
124 )
125 }
126 other => fragment(other, pretty, center, config),
127 })
128 .collect::<Vec<String>>()
129 .join("")
130 .trim(),
131 head,
132 )
133 .into_pipeline_data_with_metadata(Some(metadata)));
134 }
135 Ok(
136 Value::string(table(grouped_input, pretty, center, config), head)
137 .into_pipeline_data_with_metadata(Some(metadata)),
138 )
139}
140
141fn fragment(input: Value, pretty: bool, center: &Option<Vec<CellPath>>, config: &Config) -> String {
142 let mut out = String::new();
143
144 if let Value::Record { val, .. } = &input {
145 match val.get_index(0) {
146 Some((header, data)) if val.len() == 1 => {
147 let markup = match header.to_ascii_lowercase().as_ref() {
148 "h1" => "# ".to_string(),
149 "h2" => "## ".to_string(),
150 "h3" => "### ".to_string(),
151 "blockquote" => "> ".to_string(),
152 _ => return table(input.into_pipeline_data(), pretty, center, config),
153 };
154
155 out.push_str(&markup);
156 out.push_str(&data.to_expanded_string("|", config));
157 }
158 _ => out = table(input.into_pipeline_data(), pretty, center, config),
159 }
160 } else {
161 out = input.to_expanded_string("|", config)
162 }
163
164 out.push('\n');
165 out
166}
167
168fn collect_headers(headers: &[String]) -> (Vec<String>, Vec<usize>) {
169 let mut escaped_headers: Vec<String> = Vec::new();
170 let mut column_widths: Vec<usize> = Vec::new();
171
172 if !headers.is_empty() && (headers.len() > 1 || !headers[0].is_empty()) {
173 for header in headers {
174 let escaped_header_string = v_htmlescape::escape(header).to_string();
175 column_widths.push(escaped_header_string.len());
176 escaped_headers.push(escaped_header_string);
177 }
178 } else {
179 column_widths = vec![0; headers.len()]
180 }
181
182 (escaped_headers, column_widths)
183}
184
185fn table(
186 input: PipelineData,
187 pretty: bool,
188 center: &Option<Vec<CellPath>>,
189 config: &Config,
190) -> String {
191 let vec_of_values = input
192 .into_iter()
193 .flat_map(|val| match val {
194 Value::List { vals, .. } => vals,
195 other => vec![other],
196 })
197 .collect::<Vec<Value>>();
198 let mut headers = merge_descriptors(&vec_of_values);
199
200 let mut empty_header_index = 0;
201 for value in &vec_of_values {
202 if let Value::Record { val, .. } = value {
203 for column in val.columns() {
204 if column.is_empty() && !headers.contains(&String::new()) {
205 headers.insert(empty_header_index, String::new());
206 empty_header_index += 1;
207 break;
208 }
209 empty_header_index += 1;
210 }
211 }
212 }
213
214 let (escaped_headers, mut column_widths) = collect_headers(&headers);
215
216 let mut escaped_rows: Vec<Vec<String>> = Vec::new();
217
218 for row in vec_of_values {
219 let mut escaped_row: Vec<String> = Vec::new();
220 let span = row.span();
221
222 match row.to_owned() {
223 Value::Record { val: row, .. } => {
224 for i in 0..headers.len() {
225 let value_string = row
226 .get(&headers[i])
227 .cloned()
228 .unwrap_or_else(|| Value::nothing(span))
229 .to_expanded_string(", ", config);
230 let new_column_width = value_string.len();
231
232 escaped_row.push(value_string);
233
234 if column_widths[i] < new_column_width {
235 column_widths[i] = new_column_width;
236 }
237 }
238 }
239 p => {
240 let value_string =
241 v_htmlescape::escape(&p.to_abbreviated_string(config)).to_string();
242 escaped_row.push(value_string);
243 }
244 }
245
246 escaped_rows.push(escaped_row);
247 }
248
249 let output_string = if (column_widths.is_empty() || column_widths.iter().all(|x| *x == 0))
250 && escaped_rows.is_empty()
251 {
252 String::from("")
253 } else {
254 get_output_string(
255 &escaped_headers,
256 &escaped_rows,
257 &column_widths,
258 pretty,
259 center,
260 )
261 .trim()
262 .to_string()
263 };
264
265 output_string
266}
267
268pub fn group_by(values: PipelineData, head: Span, config: &Config) -> (PipelineData, bool) {
269 let mut lists = IndexMap::new();
270 let mut single_list = false;
271 for val in values {
272 if let Value::Record {
273 val: ref record, ..
274 } = val
275 {
276 lists
277 .entry(record.columns().map(|c| c.as_str()).collect::<String>())
278 .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
279 .or_insert_with(|| vec![val.clone()]);
280 } else {
281 lists
282 .entry(val.to_expanded_string(",", config))
283 .and_modify(|v: &mut Vec<Value>| v.push(val.clone()))
284 .or_insert_with(|| vec![val.clone()]);
285 }
286 }
287 let mut output = vec![];
288 for (_, mut value) in lists {
289 if value.len() == 1 {
290 output.push(value.pop().unwrap_or_else(|| Value::nothing(head)))
291 } else {
292 output.push(Value::list(value.to_vec(), head))
293 }
294 }
295 if output.len() == 1 {
296 single_list = true;
297 }
298 (Value::list(output, head).into_pipeline_data(), single_list)
299}
300
301fn get_output_string(
302 headers: &[String],
303 rows: &[Vec<String>],
304 column_widths: &[usize],
305 pretty: bool,
306 center: &Option<Vec<CellPath>>,
307) -> String {
308 let mut output_string = String::new();
309
310 let mut to_center: HashSet<String> = HashSet::new();
311 if let Some(center_vec) = center.as_ref() {
312 for cell_path in center_vec {
313 if let Some(PathMember::String { val, .. }) = cell_path
314 .members
315 .iter()
316 .find(|member| matches!(member, PathMember::String { .. }))
317 {
318 to_center.insert(val.clone());
319 }
320 }
321 }
322
323 if !headers.is_empty() {
324 output_string.push('|');
325
326 for i in 0..headers.len() {
327 if pretty {
328 output_string.push(' ');
329 if center.is_some() && to_center.contains(&headers[i]) {
330 output_string.push_str(&get_centered_string(
331 headers[i].clone(),
332 column_widths[i],
333 ' ',
334 ));
335 } else {
336 output_string.push_str(&get_padded_string(
337 headers[i].clone(),
338 column_widths[i],
339 ' ',
340 ));
341 }
342 output_string.push(' ');
343 } else {
344 output_string.push_str(&headers[i]);
345 }
346
347 output_string.push('|');
348 }
349
350 output_string.push_str("\n|");
351
352 for i in 0..headers.len() {
353 let centered_column = center.is_some() && to_center.contains(&headers[i]);
354 let border_char = if centered_column { ':' } else { ' ' };
355 if pretty {
356 output_string.push(border_char);
357 output_string.push_str(&get_padded_string(
358 String::from("-"),
359 column_widths[i],
360 '-',
361 ));
362 output_string.push(border_char);
363 } else if centered_column {
364 output_string.push(':');
365 output_string.push('-');
366 output_string.push(':');
367 } else {
368 output_string.push('-');
369 }
370
371 output_string.push('|');
372 }
373
374 output_string.push('\n');
375 }
376
377 for row in rows {
378 if !headers.is_empty() {
379 output_string.push('|');
380 }
381
382 for i in 0..row.len() {
383 if pretty && column_widths.get(i).is_some() {
384 output_string.push(' ');
385 if center.is_some() && to_center.contains(&headers[i]) {
386 output_string.push_str(&get_centered_string(
387 row[i].clone(),
388 column_widths[i],
389 ' ',
390 ));
391 } else {
392 output_string.push_str(&get_padded_string(
393 row[i].clone(),
394 column_widths[i],
395 ' ',
396 ));
397 }
398 output_string.push(' ');
399 } else {
400 output_string.push_str(&row[i]);
401 }
402
403 if !headers.is_empty() {
404 output_string.push('|');
405 }
406 }
407
408 output_string.push('\n');
409 }
410
411 output_string
412}
413
414fn get_centered_string(text: String, desired_length: usize, padding_character: char) -> String {
415 let total_padding = if text.len() > desired_length {
416 0
417 } else {
418 desired_length - text.len()
419 };
420
421 let repeat_left = total_padding / 2;
422 let repeat_right = total_padding - repeat_left;
423
424 format!(
425 "{}{}{}",
426 padding_character.to_string().repeat(repeat_left),
427 text,
428 padding_character.to_string().repeat(repeat_right)
429 )
430}
431
432fn get_padded_string(text: String, desired_length: usize, padding_character: char) -> String {
433 let repeat_length = if text.len() > desired_length {
434 0
435 } else {
436 desired_length - text.len()
437 };
438
439 format!(
440 "{}{}",
441 text,
442 padding_character.to_string().repeat(repeat_length)
443 )
444}
445
446#[cfg(test)]
447mod tests {
448 use crate::{Get, Metadata};
449
450 use super::*;
451 use nu_cmd_lang::eval_pipeline_without_terminal_expression;
452 use nu_protocol::{Config, IntoPipelineData, Value, casing::Casing, record};
453
454 fn one(string: &str) -> String {
455 string
456 .lines()
457 .skip(1)
458 .map(|line| line.trim())
459 .collect::<Vec<&str>>()
460 .join("\n")
461 .trim_end()
462 .to_string()
463 }
464
465 #[test]
466 fn test_examples() {
467 use crate::test_examples;
468
469 test_examples(ToMd {})
470 }
471
472 #[test]
473 fn render_h1() {
474 let value = Value::test_record(record! {
475 "H1" => Value::test_string("Ecuador"),
476 });
477
478 assert_eq!(
479 fragment(value, false, &None, &Config::default()),
480 "# Ecuador\n"
481 );
482 }
483
484 #[test]
485 fn render_h2() {
486 let value = Value::test_record(record! {
487 "H2" => Value::test_string("Ecuador"),
488 });
489
490 assert_eq!(
491 fragment(value, false, &None, &Config::default()),
492 "## Ecuador\n"
493 );
494 }
495
496 #[test]
497 fn render_h3() {
498 let value = Value::test_record(record! {
499 "H3" => Value::test_string("Ecuador"),
500 });
501
502 assert_eq!(
503 fragment(value, false, &None, &Config::default()),
504 "### Ecuador\n"
505 );
506 }
507
508 #[test]
509 fn render_blockquote() {
510 let value = Value::test_record(record! {
511 "BLOCKQUOTE" => Value::test_string("Ecuador"),
512 });
513
514 assert_eq!(
515 fragment(value, false, &None, &Config::default()),
516 "> Ecuador\n"
517 );
518 }
519
520 #[test]
521 fn render_table() {
522 let value = Value::test_list(vec![
523 Value::test_record(record! {
524 "country" => Value::test_string("Ecuador"),
525 }),
526 Value::test_record(record! {
527 "country" => Value::test_string("New Zealand"),
528 }),
529 Value::test_record(record! {
530 "country" => Value::test_string("USA"),
531 }),
532 ]);
533
534 assert_eq!(
535 table(
536 value.clone().into_pipeline_data(),
537 false,
538 &None,
539 &Config::default()
540 ),
541 one(r#"
542 |country|
543 |-|
544 |Ecuador|
545 |New Zealand|
546 |USA|
547 "#)
548 );
549
550 assert_eq!(
551 table(value.into_pipeline_data(), true, &None, &Config::default()),
552 one(r#"
553 | country |
554 | ----------- |
555 | Ecuador |
556 | New Zealand |
557 | USA |
558 "#)
559 );
560 }
561
562 #[test]
563 fn test_empty_column_header() {
564 let value = Value::test_list(vec![
565 Value::test_record(record! {
566 "" => Value::test_string("1"),
567 "foo" => Value::test_string("2"),
568 }),
569 Value::test_record(record! {
570 "" => Value::test_string("3"),
571 "foo" => Value::test_string("4"),
572 }),
573 ]);
574
575 assert_eq!(
576 table(
577 value.clone().into_pipeline_data(),
578 false,
579 &None,
580 &Config::default()
581 ),
582 one(r#"
583 ||foo|
584 |-|-|
585 |1|2|
586 |3|4|
587 "#)
588 );
589 }
590
591 #[test]
592 fn test_empty_row_value() {
593 let value = Value::test_list(vec![
594 Value::test_record(record! {
595 "foo" => Value::test_string("1"),
596 "bar" => Value::test_string("2"),
597 }),
598 Value::test_record(record! {
599 "foo" => Value::test_string("3"),
600 "bar" => Value::test_string("4"),
601 }),
602 Value::test_record(record! {
603 "foo" => Value::test_string("5"),
604 "bar" => Value::test_string(""),
605 }),
606 ]);
607
608 assert_eq!(
609 table(
610 value.clone().into_pipeline_data(),
611 false,
612 &None,
613 &Config::default()
614 ),
615 one(r#"
616 |foo|bar|
617 |-|-|
618 |1|2|
619 |3|4|
620 |5||
621 "#)
622 );
623 }
624
625 #[test]
626 fn test_center_column() {
627 let value = Value::test_list(vec![
628 Value::test_record(record! {
629 "foo" => Value::test_string("1"),
630 "bar" => Value::test_string("2"),
631 }),
632 Value::test_record(record! {
633 "foo" => Value::test_string("3"),
634 "bar" => Value::test_string("4"),
635 }),
636 Value::test_record(record! {
637 "foo" => Value::test_string("5"),
638 "bar" => Value::test_string("6"),
639 }),
640 ]);
641
642 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
643 members: vec![PathMember::test_string(
644 "bar".into(),
645 false,
646 Casing::Sensitive,
647 )],
648 })]);
649
650 let cell_path: Vec<CellPath> = center_columns
651 .into_list()
652 .unwrap()
653 .into_iter()
654 .map(|v| v.into_cell_path().unwrap())
655 .collect();
656
657 let center: Option<Vec<CellPath>> = Some(cell_path);
658
659 assert_eq!(
661 table(
662 value.clone().into_pipeline_data(),
663 true,
664 ¢er,
665 &Config::default()
666 ),
667 one(r#"
668 | foo | bar |
669 | --- |:---:|
670 | 1 | 2 |
671 | 3 | 4 |
672 | 5 | 6 |
673 "#)
674 );
675
676 assert_eq!(
678 table(
679 value.clone().into_pipeline_data(),
680 false,
681 ¢er,
682 &Config::default()
683 ),
684 one(r#"
685 |foo|bar|
686 |-|:-:|
687 |1|2|
688 |3|4|
689 |5|6|
690 "#)
691 );
692 }
693
694 #[test]
695 fn test_empty_center_column() {
696 let value = Value::test_list(vec![
697 Value::test_record(record! {
698 "foo" => Value::test_string("1"),
699 "bar" => Value::test_string("2"),
700 }),
701 Value::test_record(record! {
702 "foo" => Value::test_string("3"),
703 "bar" => Value::test_string("4"),
704 }),
705 Value::test_record(record! {
706 "foo" => Value::test_string("5"),
707 "bar" => Value::test_string("6"),
708 }),
709 ]);
710
711 let center: Option<Vec<CellPath>> = Some(vec![]);
712
713 assert_eq!(
714 table(
715 value.clone().into_pipeline_data(),
716 true,
717 ¢er,
718 &Config::default()
719 ),
720 one(r#"
721 | foo | bar |
722 | --- | --- |
723 | 1 | 2 |
724 | 3 | 4 |
725 | 5 | 6 |
726 "#)
727 );
728 }
729
730 #[test]
731 fn test_center_multiple_columns() {
732 let value = Value::test_list(vec![
733 Value::test_record(record! {
734 "command" => Value::test_string("ls"),
735 "input" => Value::test_string("."),
736 "output" => Value::test_string("file.txt"),
737 }),
738 Value::test_record(record! {
739 "command" => Value::test_string("echo"),
740 "input" => Value::test_string("'hi'"),
741 "output" => Value::test_string("hi"),
742 }),
743 Value::test_record(record! {
744 "command" => Value::test_string("cp"),
745 "input" => Value::test_string("a.txt"),
746 "output" => Value::test_string("b.txt"),
747 }),
748 ]);
749
750 let center_columns = Value::test_list(vec![
751 Value::test_cell_path(CellPath {
752 members: vec![PathMember::test_string(
753 "command".into(),
754 false,
755 Casing::Sensitive,
756 )],
757 }),
758 Value::test_cell_path(CellPath {
759 members: vec![PathMember::test_string(
760 "output".into(),
761 false,
762 Casing::Sensitive,
763 )],
764 }),
765 ]);
766
767 let cell_path: Vec<CellPath> = center_columns
768 .into_list()
769 .unwrap()
770 .into_iter()
771 .map(|v| v.into_cell_path().unwrap())
772 .collect();
773
774 let center: Option<Vec<CellPath>> = Some(cell_path);
775
776 assert_eq!(
777 table(
778 value.clone().into_pipeline_data(),
779 true,
780 ¢er,
781 &Config::default()
782 ),
783 one(r#"
784 | command | input | output |
785 |:-------:| ----- |:--------:|
786 | ls | . | file.txt |
787 | echo | 'hi' | hi |
788 | cp | a.txt | b.txt |
789 "#)
790 );
791 }
792
793 #[test]
794 fn test_center_non_existing_column() {
795 let value = Value::test_list(vec![
796 Value::test_record(record! {
797 "name" => Value::test_string("Alice"),
798 "age" => Value::test_string("30"),
799 }),
800 Value::test_record(record! {
801 "name" => Value::test_string("Bob"),
802 "age" => Value::test_string("5"),
803 }),
804 Value::test_record(record! {
805 "name" => Value::test_string("Charlie"),
806 "age" => Value::test_string("20"),
807 }),
808 ]);
809
810 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
811 members: vec![PathMember::test_string(
812 "none".into(),
813 false,
814 Casing::Sensitive,
815 )],
816 })]);
817
818 let cell_path: Vec<CellPath> = center_columns
819 .into_list()
820 .unwrap()
821 .into_iter()
822 .map(|v| v.into_cell_path().unwrap())
823 .collect();
824
825 let center: Option<Vec<CellPath>> = Some(cell_path);
826
827 assert_eq!(
828 table(
829 value.clone().into_pipeline_data(),
830 true,
831 ¢er,
832 &Config::default()
833 ),
834 one(r#"
835 | name | age |
836 | ------- | --- |
837 | Alice | 30 |
838 | Bob | 5 |
839 | Charlie | 20 |
840 "#)
841 );
842 }
843
844 #[test]
845 fn test_center_complex_cell_path() {
846 let value = Value::test_list(vec![
847 Value::test_record(record! {
848 "k" => Value::test_string("version"),
849 "v" => Value::test_string("0.104.1"),
850 }),
851 Value::test_record(record! {
852 "k" => Value::test_string("build_time"),
853 "v" => Value::test_string("2025-05-28 11:00:45 +01:00"),
854 }),
855 ]);
856
857 let center_columns = Value::test_list(vec![Value::test_cell_path(CellPath {
858 members: vec![
859 PathMember::test_int(1, false),
860 PathMember::test_string("v".into(), false, Casing::Sensitive),
861 ],
862 })]);
863
864 let cell_path: Vec<CellPath> = center_columns
865 .into_list()
866 .unwrap()
867 .into_iter()
868 .map(|v| v.into_cell_path().unwrap())
869 .collect();
870
871 let center: Option<Vec<CellPath>> = Some(cell_path);
872
873 assert_eq!(
874 table(
875 value.clone().into_pipeline_data(),
876 true,
877 ¢er,
878 &Config::default()
879 ),
880 one(r#"
881 | k | v |
882 | ---------- |:--------------------------:|
883 | version | 0.104.1 |
884 | build_time | 2025-05-28 11:00:45 +01:00 |
885 "#)
886 );
887 }
888
889 #[test]
890 fn test_content_type_metadata() {
891 let mut engine_state = Box::new(EngineState::new());
892 let state_delta = {
893 let mut working_set = StateWorkingSet::new(&engine_state);
896
897 working_set.add_decl(Box::new(ToMd {}));
898 working_set.add_decl(Box::new(Metadata {}));
899 working_set.add_decl(Box::new(Get {}));
900
901 working_set.render()
902 };
903 let delta = state_delta;
904
905 engine_state
906 .merge_delta(delta)
907 .expect("Error merging delta");
908
909 let cmd = "{a: 1 b: 2} | to md | metadata | get content_type";
910 let result = eval_pipeline_without_terminal_expression(
911 cmd,
912 std::env::temp_dir().as_ref(),
913 &mut engine_state,
914 );
915 assert_eq!(
916 Value::test_record(record!("content_type" => Value::test_string("text/markdown"))),
917 result.expect("There should be a result")
918 );
919 }
920}