From ad46eb0295f5daa88743fc4122f5d141a6e66c85 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 8 Apr 2026 19:20:44 +0200 Subject: [PATCH] fix: serialize single-item yaml lists as blocks --- src-tauri/src/frontmatter/yaml.rs | 259 ++++++++++++++---------------- 1 file changed, 125 insertions(+), 134 deletions(-) diff --git a/src-tauri/src/frontmatter/yaml.rs b/src-tauri/src/frontmatter/yaml.rs index 67fc548e..5b74e4d2 100644 --- a/src-tauri/src/frontmatter/yaml.rs +++ b/src-tauri/src/frontmatter/yaml.rs @@ -11,49 +11,72 @@ pub enum FrontmatterValue { Null, } -/// Characters that require a YAML string value to be quoted. -fn has_yaml_special_chars(s: &str) -> bool { - s.contains(':') || s.contains('#') +#[derive(Clone, Copy)] +struct YamlText<'a>(&'a str); + +impl<'a> YamlText<'a> { + /// Characters that require a YAML string value to be quoted. + fn has_special_chars(self) -> bool { + self.0.contains(':') || self.0.contains('#') + } + + /// Check if a string starts with a YAML collection indicator (array or map). + fn starts_as_collection(self) -> bool { + self.0.starts_with('[') || self.0.starts_with('{') + } + + /// Check whether a YAML string value needs quoting to avoid ambiguity. + fn needs_quoting(self) -> bool { + self.has_special_chars() + || self.starts_as_collection() + || matches!(self.0, "true" | "false" | "null") + || self.0.parse::().is_ok() + } + + /// Quote a string value for YAML, escaping internal double quotes. + fn quoted(self) -> String { + format!("\"{}\"", self.0.replace('\"', "\\\"")) + } + + /// Format a single YAML list item as ` - "value"`. + fn as_list_item(self) -> String { + format!(" - {}", self.quoted()) + } + + /// Format a multi-line string as a YAML block scalar (`|`). + /// Each line is indented by 2 spaces; empty lines are preserved as blank. + fn as_block_scalar(self) -> String { + let indented = self + .0 + .lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!(" {}", line) + } + }) + .collect::>() + .join("\n"); + format!("|\n{}", indented) + } + + /// Check whether a YAML key needs quoting (contains spaces, special chars, etc.). + fn needs_key_quoting(self) -> bool { + self.0 + .chars() + .any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-') + } } -/// Check if a string starts with a YAML collection indicator (array or map). -fn starts_as_yaml_collection(s: &str) -> bool { - s.starts_with('[') || s.starts_with('{') -} - -/// Check whether a YAML string value needs quoting to avoid ambiguity. -fn needs_yaml_quoting(s: &str) -> bool { - has_yaml_special_chars(s) - || starts_as_yaml_collection(s) - || matches!(s, "true" | "false" | "null") - || s.parse::().is_ok() -} - -/// Quote a string value for YAML, escaping internal double quotes. -fn quote_yaml_string(s: &str) -> String { - format!("\"{}\"", s.replace('\"', "\\\"")) -} - -/// Format a single YAML list item as ` - "value"`. -fn format_list_item(item: &str) -> String { - format!(" - {}", quote_yaml_string(item)) -} - -/// Format a multi-line string as a YAML block scalar (`|`). -/// Each line is indented by 2 spaces; empty lines are preserved as blank. -fn format_block_scalar(s: &str) -> String { - let indented = s - .lines() - .map(|l| { - if l.is_empty() { - String::new() - } else { - format!(" {}", l) - } - }) - .collect::>() - .join("\n"); - format!("|\n{}", indented) +/// Format a key for YAML output (quote if necessary) +pub fn format_yaml_key(key: &str) -> String { + let yaml_key = YamlText(key); + if yaml_key.needs_key_quoting() { + yaml_key.quoted() + } else { + key.to_string() + } } /// Format a number for YAML (integers without decimal, floats with). @@ -69,10 +92,11 @@ impl FrontmatterValue { pub fn to_yaml_value(&self) -> String { match self { FrontmatterValue::String(s) => { + let yaml_text = YamlText(s); if s.contains('\n') { - format_block_scalar(s) - } else if needs_yaml_quoting(s) { - quote_yaml_string(s) + yaml_text.as_block_scalar() + } else if yaml_text.needs_quoting() { + yaml_text.quoted() } else { s.clone() } @@ -82,7 +106,7 @@ impl FrontmatterValue { FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(), FrontmatterValue::List(items) => items .iter() - .map(|item| format_list_item(item)) + .map(|item| YamlText(item).as_list_item()) .collect::>() .join("\n"), FrontmatterValue::Null => "null".to_string(), @@ -90,21 +114,6 @@ impl FrontmatterValue { } } -/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.). -fn needs_key_quoting(key: &str) -> bool { - key.chars() - .any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-') -} - -/// Format a key for YAML output (quote if necessary) -pub fn format_yaml_key(key: &str) -> String { - if needs_key_quoting(key) { - format!("\"{}\"", key) - } else { - key.to_string() - } -} - /// Format a key-value pair as one or more YAML lines. pub fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec { let yaml_key = format_yaml_key(key); @@ -112,7 +121,7 @@ pub fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec { if yaml_value.starts_with("|\n") { // Block scalar: key and indicator on the same line, content follows vec![format!("{}: {}", yaml_key, yaml_value)] - } else if yaml_value.contains('\n') { + } else if matches!(value, FrontmatterValue::List(items) if !items.is_empty()) { vec![format!("{}:", yaml_key), yaml_value] } else { vec![format!("{}: {}", yaml_key, yaml_value)] @@ -123,66 +132,40 @@ pub fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec { mod tests { use super::*; - #[test] - fn test_to_yaml_value_string_needs_quoting_colon() { - let v = FrontmatterValue::String("key: value".to_string()); - assert_eq!(v.to_yaml_value(), "\"key: value\""); + fn assert_string_yaml_value(input: &str, expected: &str) { + let value = FrontmatterValue::String(input.to_string()); + assert_eq!(value.to_yaml_value(), expected); + } + + fn assert_field_lines(key: &str, value: FrontmatterValue, expected: &[&str]) { + let lines = format_yaml_field(key, &value); + let expected_lines = expected + .iter() + .map(|line| line.to_string()) + .collect::>(); + assert_eq!(lines, expected_lines); } #[test] - fn test_to_yaml_value_string_needs_quoting_hash() { - let v = FrontmatterValue::String("has # comment".to_string()); - assert_eq!(v.to_yaml_value(), "\"has # comment\""); - } - - #[test] - fn test_to_yaml_value_string_needs_quoting_bracket() { - let v = FrontmatterValue::String("[array-like]".to_string()); - assert_eq!(v.to_yaml_value(), "\"[array-like]\""); - } - - #[test] - fn test_to_yaml_value_string_needs_quoting_brace() { - let v = FrontmatterValue::String("{object-like}".to_string()); - assert_eq!(v.to_yaml_value(), "\"{object-like}\""); - } - - #[test] - fn test_to_yaml_value_string_needs_quoting_bool_like() { - assert_eq!( - FrontmatterValue::String("true".to_string()).to_yaml_value(), - "\"true\"" - ); - assert_eq!( - FrontmatterValue::String("false".to_string()).to_yaml_value(), - "\"false\"" - ); - } - - #[test] - fn test_to_yaml_value_string_needs_quoting_null_like() { - assert_eq!( - FrontmatterValue::String("null".to_string()).to_yaml_value(), - "\"null\"" - ); - } - - #[test] - fn test_to_yaml_value_string_needs_quoting_number_like() { - assert_eq!( - FrontmatterValue::String("42".to_string()).to_yaml_value(), - "\"42\"" - ); - assert_eq!( - FrontmatterValue::String("3.14".to_string()).to_yaml_value(), - "\"3.14\"" - ); + fn test_to_yaml_value_string_needs_quoting_cases() { + for (input, expected) in [ + ("key: value", "\"key: value\""), + ("has # comment", "\"has # comment\""), + ("[array-like]", "\"[array-like]\""), + ("{object-like}", "\"{object-like}\""), + ("true", "\"true\""), + ("false", "\"false\""), + ("null", "\"null\""), + ("42", "\"42\""), + ("3.14", "\"3.14\""), + ] { + assert_string_yaml_value(input, expected); + } } #[test] fn test_to_yaml_value_string_plain() { - let v = FrontmatterValue::String("Hello World".to_string()); - assert_eq!(v.to_yaml_value(), "Hello World"); + assert_string_yaml_value("Hello World", "Hello World"); } #[test] @@ -225,29 +208,22 @@ mod tests { #[test] fn test_format_yaml_key_simple() { - assert_eq!(format_yaml_key("Status"), "Status"); - assert_eq!(format_yaml_key("is_a"), "is_a"); + for (input, expected) in [("Status", "Status"), ("is_a", "is_a")] { + assert_eq!(format_yaml_key(input), expected); + } } #[test] - fn test_format_yaml_key_with_spaces() { - assert_eq!(format_yaml_key("Is A"), "\"Is A\""); - assert_eq!(format_yaml_key("Created at"), "\"Created at\""); - } - - #[test] - fn test_format_yaml_key_with_colon() { - assert_eq!(format_yaml_key("key:value"), "\"key:value\""); - } - - #[test] - fn test_format_yaml_key_with_hash() { - assert_eq!(format_yaml_key("has#tag"), "\"has#tag\""); - } - - #[test] - fn test_format_yaml_key_with_period() { - assert_eq!(format_yaml_key("key.name"), "\"key.name\""); + fn test_format_yaml_key_quotes_when_needed() { + for (input, expected) in [ + ("Is A", "\"Is A\""), + ("Created at", "\"Created at\""), + ("key:value", "\"key:value\""), + ("has#tag", "\"has#tag\""), + ("key.name", "\"key.name\""), + ] { + assert_eq!(format_yaml_key(input), expected); + } } #[test] @@ -259,4 +235,19 @@ mod tests { assert!(lines[0].contains(" ## Objective")); assert!(lines[0].contains(" ## Timeline")); } + + #[test] + fn test_format_yaml_field_list_layouts() { + assert_field_lines( + "_list_properties_display", + FrontmatterValue::List(vec!["Belongs to".to_string()]), + &["_list_properties_display:", " - \"Belongs to\""], + ); + assert_field_lines("tags", FrontmatterValue::List(vec![]), &["tags: []"]); + assert_field_lines( + "tags", + FrontmatterValue::List(vec!["Alpha".to_string(), "Beta".to_string()]), + &["tags:", " - \"Alpha\"\n - \"Beta\""], + ); + } }