Skip to main content

omni_dev/data/
yaml.rs

1//! YAML processing utilities.
2
3use std::fs;
4use std::path::Path;
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use yaml_rust_davvid::YamlEmitter;
9
10/// Serializes a data structure to a YAML string with proper multi-line formatting.
11pub fn to_yaml<T: Serialize>(data: &T) -> Result<String> {
12    use tracing::debug;
13
14    debug!("Starting YAML serialization with hybrid approach");
15
16    // First convert to serde_yaml::Value, then to yaml-rust format
17    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    // Use yaml-rust emitter with multiline strings enabled
24    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
42/// Converts a `serde_yaml::Value` to `yaml_rust_davvid::Yaml`.
43fn 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            // For now, just convert normally - yaml-rust will make formatting decisions
66            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            // Handle tagged values by converting the inner value
83            convert_serde_to_yaml_rust(&tagged.value)
84        }
85    }
86}
87
88/// Deserializes a YAML string to a data structure.
89pub 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
113/// Reads and parses a YAML file.
114pub 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
121/// Writes a data structure to a YAML file.
122pub 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        // Should use literal block scalar (|) for multiline strings
154        assert!(yaml_output.contains("diff_content: |"));
155        assert!(yaml_output.contains("description: |"));
156
157        // Should contain the actual content without escaped newlines
158        assert!(yaml_output.contains("diff --git"));
159        assert!(yaml_output.contains("--- a/file.txt"));
160        assert!(yaml_output.contains("+++ b/file.txt"));
161
162        // Should not contain escaped newlines in the output
163        assert!(!yaml_output.contains("\\n"));
164
165        // Should round-trip correctly (accounting for trailing newlines added by literal blocks)
166        let deserialized: TestDiffContent = from_yaml(&yaml_output).unwrap();
167
168        // The description should be preserved exactly
169        assert_eq!(test_data.description, deserialized.description);
170
171        // The diff_content may have a trailing newline added by YAML literal block formatting
172        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        // YAML literal blocks may add trailing newlines, so check content preservation
189        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        // Simulate EXACTLY what the AI is generating based on the debug logs
199        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        // This should parse correctly using our hybrid approach
258        #[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        // Verify the multiline content is preserved properly
293        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        // Verify the description is much longer than 11 characters
301        assert!(
302            pr_content.description.len() > 100,
303            "Description should be long, got {}",
304            pr_content.description.len()
305        );
306    }
307
308    // ── Edge cases for YAML serialization ────────────────────────────
309
310    #[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}