1use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use yaml_rust_davvid::YamlEmitter;
9
10pub fn to_yaml<T: Serialize>(data: &T) -> Result<String> {
12 use tracing::debug;
13
14 debug!("Starting YAML serialization with hybrid approach");
15
16 let serde_value = serde_yaml::to_value(data).context("Failed to serialize to serde value")?;
18 debug!("Converted to serde_yaml::Value successfully");
19
20 let yaml_rust_value = convert_serde_to_yaml_rust(&serde_value)?;
21 debug!("Converted to yaml-rust format successfully");
22
23 let mut output = String::new();
25 let mut emitter = YamlEmitter::new(&mut output);
26 emitter.multiline_strings(true);
27 debug!("Created YamlEmitter with multiline_strings(true)");
28
29 emitter
30 .dump(&yaml_rust_value)
31 .context("Failed to emit YAML")?;
32
33 debug!(
34 output_length = output.len(),
35 output_preview = %output.lines().take(10).collect::<Vec<_>>().join("\\n"),
36 "YAML serialization completed"
37 );
38
39 Ok(output)
40}
41
42fn convert_serde_to_yaml_rust(value: &serde_yaml::Value) -> Result<yaml_rust_davvid::Yaml> {
44 use tracing::debug;
45 use yaml_rust_davvid::Yaml;
46
47 match value {
48 serde_yaml::Value::Null => Ok(Yaml::Null),
49 serde_yaml::Value::Bool(b) => Ok(Yaml::Boolean(*b)),
50 serde_yaml::Value::Number(n) => {
51 if let Some(i) = n.as_i64() {
52 Ok(Yaml::Integer(i))
53 } else if let Some(f) = n.as_f64() {
54 Ok(Yaml::Real(f.to_string()))
55 } else {
56 Ok(Yaml::String(n.to_string()))
57 }
58 }
59 serde_yaml::Value::String(s) => {
60 debug!(
61 string_length = s.len(),
62 string_preview = %s.lines().take(3).collect::<Vec<_>>().join("\\n"),
63 "Converting string value to yaml-rust"
64 );
65 Ok(Yaml::String(s.clone()))
67 }
68 serde_yaml::Value::Sequence(seq) => {
69 let yaml_seq: Result<Vec<_>> = seq.iter().map(convert_serde_to_yaml_rust).collect();
70 Ok(Yaml::Array(yaml_seq?))
71 }
72 serde_yaml::Value::Mapping(map) => {
73 let mut yaml_map = yaml_rust_davvid::yaml::Hash::new();
74 for (k, v) in map {
75 let yaml_key = convert_serde_to_yaml_rust(k)?;
76 let yaml_value = convert_serde_to_yaml_rust(v)?;
77 yaml_map.insert(yaml_key, yaml_value);
78 }
79 Ok(Yaml::Hash(yaml_map))
80 }
81 serde_yaml::Value::Tagged(tagged) => {
82 convert_serde_to_yaml_rust(&tagged.value)
84 }
85 }
86}
87
88pub fn from_yaml<T: for<'de> Deserialize<'de>>(yaml: &str) -> Result<T> {
90 use tracing::debug;
91
92 debug!(
93 yaml_length = yaml.len(),
94 yaml_preview = %yaml.lines().take(10).collect::<Vec<_>>().join("\\n"),
95 "Deserializing YAML using serde_yaml"
96 );
97
98 let result = serde_yaml::from_str(yaml).context("Failed to deserialize YAML");
99
100 debug!(
101 success = result.is_ok(),
102 error = result
103 .as_ref()
104 .err()
105 .map(std::string::ToString::to_string)
106 .unwrap_or_default(),
107 "YAML deserialization result"
108 );
109
110 result
111}
112
113pub fn read_yaml_file<T: for<'de> Deserialize<'de>, P: AsRef<Path>>(path: P) -> Result<T> {
115 let content = fs::read_to_string(&path)
116 .with_context(|| format!("Failed to read file: {}", path.as_ref().display()))?;
117
118 from_yaml(&content)
119}
120
121pub fn write_yaml_file<T: Serialize, P: AsRef<Path>>(data: &T, path: P) -> Result<()> {
123 let yaml_content = to_yaml(data)?;
124
125 fs::write(&path, yaml_content)
126 .with_context(|| format!("Failed to write file: {}", path.as_ref().display()))?;
127
128 Ok(())
129}
130
131#[cfg(test)]
132#[allow(clippy::unwrap_used, clippy::expect_used)]
133mod tests {
134 use super::*;
135 use serde::{Deserialize, Serialize};
136
137 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138 struct TestDiffContent {
139 diff_content: String,
140 description: String,
141 }
142
143 #[test]
144 fn multiline_yaml_with_literal_blocks() {
145 let test_data = TestDiffContent {
146 diff_content: "diff --git a/file.txt b/file.txt\nindex 123..456 100644\n--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n-old line\n+new line".to_string(),
147 description: "This is a\nmultiline\ndescription".to_string(),
148 };
149
150 let yaml_output = to_yaml(&test_data).unwrap();
151 println!("YAML Output:\n{yaml_output}");
152
153 assert!(yaml_output.contains("diff_content: |"));
155 assert!(yaml_output.contains("description: |"));
156
157 assert!(yaml_output.contains("diff --git"));
159 assert!(yaml_output.contains("--- a/file.txt"));
160 assert!(yaml_output.contains("+++ b/file.txt"));
161
162 assert!(!yaml_output.contains("\\n"));
164
165 let deserialized: TestDiffContent = from_yaml(&yaml_output).unwrap();
167
168 assert_eq!(test_data.description, deserialized.description);
170
171 assert!(
173 deserialized.diff_content == test_data.diff_content
174 || deserialized.diff_content == format!("{}\n", test_data.diff_content)
175 );
176 }
177
178 #[test]
179 fn yaml_round_trip_preserves_content() {
180 let original = TestDiffContent {
181 diff_content: "line1\nline2\nline3".to_string(),
182 description: "desc line1\ndesc line2".to_string(),
183 };
184
185 let yaml_output = to_yaml(&original).unwrap();
186 let deserialized: TestDiffContent = from_yaml(&yaml_output).unwrap();
187
188 assert_eq!(original.description, deserialized.description);
190 assert!(
191 deserialized.diff_content == original.diff_content
192 || deserialized.diff_content == format!("{}\n", original.diff_content)
193 );
194 }
195
196 #[test]
197 fn ai_response_like_yaml_parsing() {
198 let ai_response_yaml = r#"title: "deps(test): upgrade hedgehog-extras to 0.10.0.0"
200description: |
201 # Changelog
202
203 ```yaml
204 - description: |
205 Upgrade hedgehog-extras dependency from 0.7.1+ to ^>=0.10.0.0 to access newer testing utilities and improvements. Updated type constraints and imports to maintain compatibility with the enhanced testing framework.
206 type:
207 - test # fixes/modifies tests
208 - maintenance # not directly related to the code
209 ```
210
211 # Context
212
213 This PR upgrades the `hedgehog-extras` testing library from version 0.7.1+ to 0.10.0.0 to leverage newer testing utilities and improvements. The upgrade requires several compatibility changes to maintain existing test functionality while accessing the enhanced testing framework capabilities.
214
215 The changes ensure that the Cardano CLI test suite continues to work correctly with the updated dependency while taking advantage of improvements in the newer version of hedgehog-extras.
216
217 # How to trust this PR
218
219 **Key areas to review:**
220
221 1. **Dependency constraint update** in `cardano-cli/cardano-cli.cabal` - verify the version constraint change from `>=0.7.1` to `^>=0.10`
222
223 2. **Type signature enhancement** in `Test/Cli/Run/Hash.hs` - the `hash_trip_fun` function now includes additional type constraints (`MonadBaseControl IO m` and `H.MonadAssertion m`) required by hedgehog-extras 0.10
224
225 3. **Import additions** - new imports for `FlexibleContexts` language extension and `MonadBaseControl` to support the updated API
226
227 **Commands to verify the changes:**
228 ```bash
229 # Verify the project builds with new dependencies
230 cabal build cardano-cli-test-lib
231
232 # Run the hash tests specifically
233 cabal test cardano-cli-test --test-options="--pattern Hash"
234
235 # Check that all tests still pass
236 cabal test cardano-cli-test
237 ```
238
239 **Specific changes made:**
240
241 - **cabal.project**: Updated Hackage index-state from 2025-06-22 to 2025-09-10 for latest package availability
242 - **cardano-cli.cabal**: Changed hedgehog-extras constraint from `>=0.7.1` to `^>=0.10`
243 - **Test/Cli/Run/Hash.hs**:
244 - Added `FlexibleContexts` language extension
245 - Imported `MonadBaseControl` from `Control.Monad.Trans.Control`
246 - Extended `hash_trip_fun` type signature with `MonadBaseControl IO m` and `H.MonadAssertion m` constraints
247 - **flake.lock**: Updated dependency hashes to reflect the new package versions
248
249 The type constraint additions are necessary because hedgehog-extras 0.10 has enhanced its monad transformer support, requiring these additional capabilities for proper test execution.
250
251 # Checklist
252
253 - [x] Commit sequence broadly makes sense and commits have useful messages
254 - [x] New tests are added if needed and existing tests are updated. See [Running tests](https://github.com/input-output-hk/cardano-node-wiki/wiki/Running-tests) for more details
255 - [x] Self-reviewed the diff"#;
256
257 #[derive(serde::Deserialize)]
259 struct PrContent {
260 title: String,
261 description: String,
262 }
263
264 println!("Testing YAML parsing with AI response...");
265 println!("Input length: {} chars", ai_response_yaml.len());
266 println!(
267 "First 200 chars: {}",
268 &ai_response_yaml[..200.min(ai_response_yaml.len())]
269 );
270
271 let pr_content: PrContent = from_yaml(ai_response_yaml).unwrap();
272
273 println!("Parsed title: {}", pr_content.title);
274 println!(
275 "Parsed description length: {}",
276 pr_content.description.len()
277 );
278 println!("Description first 3 lines:");
279 for (i, line) in pr_content.description.lines().take(3).enumerate() {
280 println!(" {}: {}", i + 1, line);
281 }
282
283 assert_eq!(
284 pr_content.title,
285 "deps(test): upgrade hedgehog-extras to 0.10.0.0"
286 );
287 assert!(pr_content.description.contains("# Changelog"));
288 assert!(pr_content.description.contains("# How to trust this PR"));
289 assert!(pr_content.description.contains("**Key areas to review:**"));
290 assert!(pr_content.description.contains("# Checklist"));
291
292 let lines: Vec<&str> = pr_content.description.lines().collect();
294 assert!(
295 lines.len() > 20,
296 "Should have many lines, got {}",
297 lines.len()
298 );
299
300 assert!(
302 pr_content.description.len() > 100,
303 "Description should be long, got {}",
304 pr_content.description.len()
305 );
306 }
307
308 #[derive(Debug, Serialize, Deserialize, PartialEq)]
311 struct NullableFields {
312 required: String,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 optional: Option<String>,
315 }
316
317 #[test]
318 fn yaml_optional_field_none_skipped() {
319 let data = NullableFields {
320 required: "present".to_string(),
321 optional: None,
322 };
323 let yaml = to_yaml(&data).unwrap();
324 assert!(yaml.contains("required:"));
325 assert!(!yaml.contains("optional:"));
326 }
327
328 #[test]
329 fn yaml_optional_field_some_included() {
330 let data = NullableFields {
331 required: "present".to_string(),
332 optional: Some("also present".to_string()),
333 };
334 let yaml = to_yaml(&data).unwrap();
335 assert!(yaml.contains("required:"));
336 assert!(yaml.contains("optional:"));
337 assert!(yaml.contains("also present"));
338 }
339
340 #[derive(Debug, Serialize, Deserialize, PartialEq)]
341 struct NestedData {
342 outer: String,
343 inner: InnerData,
344 }
345
346 #[derive(Debug, Serialize, Deserialize, PartialEq)]
347 struct InnerData {
348 value: i32,
349 items: Vec<String>,
350 }
351
352 #[test]
353 fn yaml_nested_structure_roundtrip() {
354 let data = NestedData {
355 outer: "top".to_string(),
356 inner: InnerData {
357 value: 42,
358 items: vec!["a".to_string(), "b".to_string(), "c".to_string()],
359 },
360 };
361 let yaml = to_yaml(&data).unwrap();
362 let restored: NestedData = from_yaml(&yaml).unwrap();
363 assert_eq!(restored, data);
364 }
365
366 #[test]
367 fn yaml_empty_sequence() {
368 let data = InnerData {
369 value: 0,
370 items: vec![],
371 };
372 let yaml = to_yaml(&data).unwrap();
373 let restored: InnerData = from_yaml(&yaml).unwrap();
374 assert_eq!(restored.items.len(), 0);
375 }
376
377 #[test]
378 fn yaml_special_characters_roundtrip() {
379 let data = TestDiffContent {
380 diff_content: "line with 'quotes' and \"double quotes\"".to_string(),
381 description: "colons: here, #hashes, [brackets], {braces}".to_string(),
382 };
383 let yaml = to_yaml(&data).unwrap();
384 let restored: TestDiffContent = from_yaml(&yaml).unwrap();
385 assert_eq!(restored.diff_content, data.diff_content);
386 assert_eq!(restored.description, data.description);
387 }
388
389 #[test]
390 fn yaml_boolean_and_numeric_roundtrip() {
391 #[derive(Debug, Serialize, Deserialize, PartialEq)]
392 struct MixedTypes {
393 flag: bool,
394 count: i64,
395 ratio: f64,
396 name: String,
397 }
398
399 let data = MixedTypes {
400 flag: true,
401 count: 42,
402 ratio: 1.5,
403 name: "test".to_string(),
404 };
405 let yaml = to_yaml(&data).unwrap();
406 let restored: MixedTypes = from_yaml(&yaml).unwrap();
407 assert_eq!(restored, data);
408 }
409
410 #[test]
411 fn yaml_file_roundtrip() -> Result<()> {
412 use tempfile::TempDir;
413
414 let dir = {
415 std::fs::create_dir_all("tmp")?;
416 TempDir::new_in("tmp")?
417 };
418 let path = dir.path().join("test.yaml");
419
420 let data = TestDiffContent {
421 diff_content: "diff content here\nwith lines".to_string(),
422 description: "a description".to_string(),
423 };
424
425 write_yaml_file(&data, &path)?;
426 let restored: TestDiffContent = read_yaml_file(&path)?;
427 assert_eq!(restored.description, data.description);
428 Ok(())
429 }
430
431 #[test]
432 fn yaml_read_nonexistent_file_fails() {
433 let result: Result<TestDiffContent> = read_yaml_file("/nonexistent/path.yaml");
434 assert!(result.is_err());
435 }
436
437 #[test]
438 fn from_yaml_invalid_input() {
439 let result: Result<TestDiffContent> = from_yaml("not: valid: yaml: [{{");
440 assert!(result.is_err());
441 }
442}