Skip to content

Commit 38305da

Browse files
committed
refactor: use serde_yaml for YAML metadata serialization
Replace hand-rolled yaml_escape with serde_yaml library for proper YAML output. Add metadata_to_yaml helper that builds a serde_yaml::Mapping and handles nested values (e.g. nutrition) correctly.
1 parent fb90d65 commit 38305da

13 files changed

Lines changed: 115 additions & 63 deletions

Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ whatlang = "0.16"
4747
# Only enable required tokio features - saves ~100KB
4848
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "fs"] }
4949
uniffi = { version = "0.28", optional = true }
50+
serde_yaml = "0.9"
5051

5152
[dev-dependencies]
5253
mockito = "1.5.0"

src/model.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::pipelines::yaml_escape;
1+
use crate::pipelines::metadata_to_yaml;
22
use serde::Serialize;
33
use std::collections::HashMap;
44

@@ -17,24 +17,28 @@ impl Recipe {
1717
pub fn to_text_with_metadata(&self) -> String {
1818
let mut output = String::new();
1919

20-
// Build metadata including name
21-
let mut metadata = self.metadata.clone();
20+
// Build metadata entries including name
21+
let mut entries: Vec<(String, String)> = Vec::new();
2222
if !self.name.is_empty() {
23-
metadata.insert("title".to_string(), self.name.clone());
23+
entries.push(("title".to_string(), self.name.clone()));
2424
}
2525
if let Some(desc) = &self.description {
26-
metadata.insert("description".to_string(), desc.clone());
26+
entries.push(("description".to_string(), desc.clone()));
2727
}
28-
// Preserve image array as comma-separated string
2928
if !self.image.is_empty() {
30-
metadata.insert("__image__".to_string(), self.image.join(", "));
29+
entries.push(("__image__".to_string(), self.image.join(", ")));
30+
}
31+
for (key, value) in &self.metadata {
32+
entries.push((key.clone(), value.clone()));
3133
}
3234

3335
// YAML frontmatter
34-
if !metadata.is_empty() {
36+
let yaml = metadata_to_yaml(&entries);
37+
if !yaml.is_empty() {
3538
output.push_str("---\n");
36-
for (key, value) in &metadata {
37-
output.push_str(&format!("{}: {}\n", key, yaml_escape(value)));
39+
output.push_str(&yaml);
40+
if !yaml.ends_with('\n') {
41+
output.push('\n');
3842
}
3943
output.push_str("---\n\n");
4044
}

src/pipelines/mod.rs

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,33 +19,37 @@ pub fn sanitize_name(name: &str) -> String {
1919
name.split_whitespace().collect::<Vec<_>>().join(" ")
2020
}
2121

22-
/// Escape a YAML value by wrapping it in double quotes if it contains
23-
/// characters that are special in YAML (e.g. `:`, `#`, `[`, `]`, `{`, `}`).
22+
/// Serialize a YAML scalar value using serde_yaml.
2423
pub fn yaml_escape(value: &str) -> String {
25-
if value.contains(':')
26-
|| value.contains('#')
27-
|| value.contains('[')
28-
|| value.contains(']')
29-
|| value.contains('{')
30-
|| value.contains('}')
31-
|| value.contains('"')
32-
|| value.contains('\'')
33-
|| value.contains('*')
34-
|| value.contains('&')
35-
|| value.contains('!')
36-
|| value.contains('|')
37-
|| value.contains('>')
38-
|| value.contains('%')
39-
|| value.contains('@')
40-
|| value.contains('`')
41-
|| value.starts_with(' ')
42-
|| value.ends_with(' ')
43-
{
44-
// Escape existing double quotes and backslashes, then wrap
45-
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
46-
format!("\"{}\"", escaped)
24+
let yaml = serde_yaml::to_string(&value).unwrap_or_else(|_| value.to_string());
25+
yaml.trim_end().to_string()
26+
}
27+
28+
/// Build a YAML metadata string from a Recipe's fields.
29+
/// Handles nested values (e.g. nutrition) by parsing pre-formatted YAML blocks.
30+
pub fn metadata_to_yaml(entries: &[(String, String)]) -> String {
31+
use serde_yaml::Value;
32+
33+
let mut mapping = serde_yaml::Mapping::new();
34+
35+
for (key, value) in entries {
36+
if value.starts_with('\n') {
37+
// Pre-formatted nested YAML (e.g. nutrition) — parse as nested mapping
38+
let yaml_str = format!("{}:{}", key, value);
39+
if let Ok(parsed) = serde_yaml::from_str::<serde_yaml::Mapping>(&yaml_str) {
40+
for (k, v) in parsed {
41+
mapping.insert(k, v);
42+
}
43+
continue;
44+
}
45+
}
46+
mapping.insert(Value::String(key.clone()), Value::String(value.clone()));
47+
}
48+
49+
if mapping.is_empty() {
50+
String::new()
4751
} else {
48-
value.to_string()
52+
serde_yaml::to_string(&mapping).unwrap_or_default()
4953
}
5054
}
5155

@@ -60,25 +64,52 @@ mod tests {
6064

6165
#[test]
6266
fn test_yaml_escape_colon() {
63-
assert_eq!(yaml_escape("test : sub"), "\"test : sub\"");
67+
assert_eq!(yaml_escape("test : sub"), "'test : sub'");
6468
}
6569

6670
#[test]
6771
fn test_yaml_escape_url() {
6872
assert_eq!(
6973
yaml_escape("http://example.com/recipe"),
70-
"\"http://example.com/recipe\""
74+
"http://example.com/recipe"
7175
);
7276
}
7377

7478
#[test]
75-
fn test_yaml_escape_with_quotes() {
76-
assert_eq!(yaml_escape("say \"hello\""), "\"say \\\"hello\\\"\"");
79+
fn test_yaml_escape_hash() {
80+
assert_eq!(yaml_escape("value # comment"), "'value # comment'");
81+
}
82+
83+
#[test]
84+
fn test_metadata_to_yaml_simple() {
85+
let entries = vec![
86+
("source".to_string(), "http://example.com".to_string()),
87+
("servings".to_string(), "4".to_string()),
88+
];
89+
let yaml = metadata_to_yaml(&entries);
90+
assert!(yaml.contains("source: http://example.com"));
91+
assert!(yaml.contains("servings: '4'"));
7792
}
7893

7994
#[test]
80-
fn test_yaml_escape_hash() {
81-
assert_eq!(yaml_escape("value # comment"), "\"value # comment\"");
95+
fn test_metadata_to_yaml_with_colon() {
96+
let entries = vec![("description".to_string(), "test : sub".to_string())];
97+
let yaml = metadata_to_yaml(&entries);
98+
assert!(yaml.contains("description: 'test : sub'"));
99+
}
100+
101+
#[test]
102+
fn test_metadata_to_yaml_nested() {
103+
let entries = vec![(
104+
"nutrition".to_string(),
105+
"\n calories: 330 calories\n fat: 18 grams fat".to_string(),
106+
)];
107+
let yaml = metadata_to_yaml(&entries);
108+
assert!(yaml.contains("nutrition:"));
109+
assert!(yaml.contains("calories: 330 calories"));
110+
assert!(yaml.contains("fat: 18 grams fat"));
111+
// Should NOT be quoted as a single string
112+
assert!(!yaml.contains("\""));
82113
}
83114

84115
#[test]

src/pipelines/url.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,21 +115,21 @@ fn recipe_to_components(recipe: &crate::model::Recipe) -> RecipeComponents {
115115
text.push_str(recipe.instructions.trim_start());
116116

117117
// Build metadata YAML (without --- delimiters)
118-
let mut metadata_lines = Vec::new();
118+
let mut entries = Vec::new();
119119
if let Some(desc) = &recipe.description {
120-
metadata_lines.push(format!("description: {}", super::yaml_escape(desc)));
120+
entries.push(("description".to_string(), desc.clone()));
121121
}
122122
// Only use the first image if multiple are available
123123
if let Some(first_image) = recipe.image.first() {
124-
metadata_lines.push(format!("image: {}", super::yaml_escape(first_image)));
124+
entries.push(("image".to_string(), first_image.clone()));
125125
}
126126
for (key, value) in &recipe.metadata {
127-
metadata_lines.push(format!("{}: {}", key, super::yaml_escape(value)));
127+
entries.push((key.clone(), value.clone()));
128128
}
129129

130130
RecipeComponents {
131131
text,
132-
metadata: metadata_lines.join("\n"),
132+
metadata: super::metadata_to_yaml(&entries),
133133
name: super::sanitize_name(&recipe.name),
134134
}
135135
}

src/url_to_text/text/extractor.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,15 @@ impl TextExtractor {
5151
let name = json["title"].as_str().unwrap_or("").to_string();
5252

5353
// Build metadata YAML from available fields
54-
let mut metadata_lines = vec![format!("source: {}", crate::pipelines::yaml_escape(source))];
54+
let mut entries = vec![("source".to_string(), source.to_string())];
5555
for field in ["servings", "prep_time", "cook_time", "total_time"] {
5656
if let Some(val) = json[field].as_str() {
5757
if !val.is_empty() {
58-
metadata_lines.push(format!(
59-
"{}: {}",
60-
field,
61-
crate::pipelines::yaml_escape(val)
62-
));
58+
entries.push((field.to_string(), val.to_string()));
6359
}
6460
}
6561
}
66-
let metadata = metadata_lines.join("\n");
62+
let metadata = crate::pipelines::metadata_to_yaml(&entries);
6763

6864
// Format ingredients as newline-separated list
6965
let ingredients = json["ingredients"]
@@ -148,7 +144,7 @@ mod tests {
148144

149145
assert_eq!(components.name, "Test Recipe");
150146
assert!(components.metadata.contains("source: test-source"));
151-
assert!(components.metadata.contains("servings: 4"));
147+
assert!(components.metadata.contains("servings: '4'"));
152148
assert!(components.metadata.contains("prep_time: 10 min"));
153149
assert!(components.metadata.contains("cook_time: 20 min"));
154150
assert!(components.metadata.contains("total_time: 30 min"));

tests/json_ld_metadata_test.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ async fn test_metadata_with_numeric_yield() {
109109
let url = format!("{}/recipe", server.url());
110110
let result = url_to_recipe(&url).await.unwrap();
111111

112-
assert!(result.metadata.contains("servings: 4"));
112+
assert!(result.metadata.contains("servings: '4'"));
113113
}
114114

115115
#[tokio::test]

tests/test_author_id_only.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ async fn test_author_with_only_id() {
9696
assert!(result.metadata.contains("time required: 40 minutes"));
9797
assert!(result.metadata.contains("course: Salad, Side Dish"));
9898
assert!(result.metadata.contains("cuisine: American"));
99-
assert!(result.metadata.contains("servings: 10"));
99+
assert!(result.metadata.contains("servings: '10'"));
100100
assert!(result.metadata.contains(
101101
"tags: Barbecue, BLT pasta salad, Food for a Crowd, pasta, pasta salad, Potluck"
102102
));

tests/test_case_insensitive_type.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ async fn test_lowercase_recipe_type() {
9090
assert!(result.metadata.contains("prep time: 10 minutes"));
9191
assert!(result.metadata.contains("cook time: 30 minutes"));
9292
assert!(result.metadata.contains("time required: 40 minutes"));
93-
assert!(result.metadata.contains("servings: 6"));
93+
assert!(result.metadata.contains("servings: '6'"));
9494
assert!(result.metadata.contains("course: Soup"));
9595
assert!(result.metadata.contains("cuisine: Mexican"));
9696
assert!(result

tests/test_download_mode.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ async fn test_download_mode_with_metadata() {
7777
assert!(stdout.contains("cuisine: Italian"));
7878
assert!(stdout.contains("servings: 4 servings"));
7979
assert!(stdout.contains("tags: test, recipe, metadata"));
80-
assert!(stdout.contains(&format!("source: \"{}\"", url)));
80+
assert!(stdout.contains(&format!("source: {}", url)));
8181
assert!(stdout.contains("title: Test Recipe"));
8282

8383
// Check that content is included
@@ -121,7 +121,7 @@ async fn test_download_mode_without_metadata() {
121121

122122
// Should still have frontmatter with at least the source URL and title
123123
assert!(stdout.contains("---\n"));
124-
assert!(stdout.contains(&format!("source: \"{}\"", url)));
124+
assert!(stdout.contains(&format!("source: {}", url)));
125125
assert!(stdout.contains("title: Simple Recipe"));
126126

127127
// Check basic content

0 commit comments

Comments
 (0)