ricecoder_specs/
format_conversion.rs

1//! Format conversion utilities for converting between YAML and Markdown spec formats
2
3use crate::error::SpecError;
4use crate::parsers::{MarkdownParser, YamlParser};
5use crate::validation::ValidationEngine;
6
7/// Converts specs between YAML and Markdown formats
8pub struct FormatConverter;
9
10impl FormatConverter {
11    /// Convert a spec from YAML format to Markdown format
12    ///
13    /// # Arguments
14    /// * `yaml_content` - The YAML spec content as a string
15    ///
16    /// # Returns
17    /// The spec converted to Markdown format, or an error if parsing/validation fails
18    ///
19    /// # Errors
20    /// Returns `SpecError` if:
21    /// - The YAML content cannot be parsed
22    /// - The parsed spec fails validation
23    pub fn yaml_to_markdown(yaml_content: &str) -> Result<String, SpecError> {
24        // Parse YAML to Spec
25        let spec = YamlParser::parse(yaml_content)?;
26
27        // Validate the parsed spec
28        ValidationEngine::validate(&spec)?;
29
30        // Serialize to Markdown
31        MarkdownParser::serialize(&spec)
32    }
33
34    /// Convert a spec from Markdown format to YAML format
35    ///
36    /// # Arguments
37    /// * `markdown_content` - The Markdown spec content as a string
38    ///
39    /// # Returns
40    /// The spec converted to YAML format, or an error if parsing/validation fails
41    ///
42    /// # Errors
43    /// Returns `SpecError` if:
44    /// - The Markdown content cannot be parsed
45    /// - The parsed spec fails validation
46    pub fn markdown_to_yaml(markdown_content: &str) -> Result<String, SpecError> {
47        // Parse Markdown to Spec
48        let spec = MarkdownParser::parse(markdown_content)?;
49
50        // Validate the parsed spec
51        ValidationEngine::validate(&spec)?;
52
53        // Serialize to YAML
54        YamlParser::serialize(&spec)
55    }
56
57    /// Convert a spec from one format to another
58    ///
59    /// # Arguments
60    /// * `content` - The spec content as a string
61    /// * `from_format` - The source format ("yaml" or "markdown")
62    /// * `to_format` - The target format ("yaml" or "markdown")
63    ///
64    /// # Returns
65    /// The spec converted to the target format, or an error if conversion fails
66    ///
67    /// # Errors
68    /// Returns `SpecError` if:
69    /// - The source format is invalid
70    /// - The content cannot be parsed
71    /// - The parsed spec fails validation
72    pub fn convert(content: &str, from_format: &str, to_format: &str) -> Result<String, SpecError> {
73        let from_lower = from_format.to_lowercase();
74        let to_lower = to_format.to_lowercase();
75
76        match (from_lower.as_str(), to_lower.as_str()) {
77            ("yaml", "markdown") => Self::yaml_to_markdown(content),
78            ("markdown", "yaml") => Self::markdown_to_yaml(content),
79            ("yaml", "yaml") => {
80                // YAML to YAML: parse and re-serialize to normalize
81                let spec = YamlParser::parse(content)?;
82                ValidationEngine::validate(&spec)?;
83                YamlParser::serialize(&spec)
84            }
85            ("markdown", "markdown") => {
86                // Markdown to Markdown: parse and re-serialize to normalize
87                let spec = MarkdownParser::parse(content)?;
88                ValidationEngine::validate(&spec)?;
89                MarkdownParser::serialize(&spec)
90            }
91            _ => Err(SpecError::InvalidFormat(format!(
92                "Unsupported format conversion: {} to {}",
93                from_format, to_format
94            ))),
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::models::*;
103    use chrono::Utc;
104
105    #[test]
106    fn test_yaml_to_markdown_conversion() {
107        let spec = Spec {
108            id: "test-spec".to_string(),
109            name: "Test Spec".to_string(),
110            version: "1.0".to_string(),
111            requirements: vec![],
112            design: None,
113            tasks: vec![],
114            metadata: SpecMetadata {
115                author: Some("Test Author".to_string()),
116                created_at: Utc::now(),
117                updated_at: Utc::now(),
118                phase: SpecPhase::Requirements,
119                status: SpecStatus::Draft,
120            },
121            inheritance: None,
122        };
123
124        let yaml = YamlParser::serialize(&spec).expect("Failed to serialize to YAML");
125        let markdown =
126            FormatConverter::yaml_to_markdown(&yaml).expect("Failed to convert YAML to Markdown");
127
128        assert!(markdown.contains("# Test Spec"));
129        assert!(markdown.contains("test-spec"));
130        assert!(markdown.contains("Test Author"));
131    }
132
133    #[test]
134    fn test_markdown_to_yaml_conversion() {
135        let spec = Spec {
136            id: "test-spec".to_string(),
137            name: "Test Spec".to_string(),
138            version: "1.0".to_string(),
139            requirements: vec![],
140            design: None,
141            tasks: vec![],
142            metadata: SpecMetadata {
143                author: Some("Test Author".to_string()),
144                created_at: Utc::now(),
145                updated_at: Utc::now(),
146                phase: SpecPhase::Requirements,
147                status: SpecStatus::Draft,
148            },
149            inheritance: None,
150        };
151
152        let markdown = MarkdownParser::serialize(&spec).expect("Failed to serialize to Markdown");
153        let yaml = FormatConverter::markdown_to_yaml(&markdown)
154            .expect("Failed to convert Markdown to YAML");
155
156        assert!(yaml.contains("id: test-spec"));
157        assert!(yaml.contains("name: Test Spec"));
158        assert!(yaml.contains("version: '1.0'"));
159    }
160
161    #[test]
162    fn test_convert_yaml_to_markdown() {
163        let spec = Spec {
164            id: "test-spec".to_string(),
165            name: "Test Spec".to_string(),
166            version: "1.0".to_string(),
167            requirements: vec![],
168            design: None,
169            tasks: vec![],
170            metadata: SpecMetadata {
171                author: None,
172                created_at: Utc::now(),
173                updated_at: Utc::now(),
174                phase: SpecPhase::Requirements,
175                status: SpecStatus::Draft,
176            },
177            inheritance: None,
178        };
179
180        let yaml = YamlParser::serialize(&spec).expect("Failed to serialize to YAML");
181        let markdown =
182            FormatConverter::convert(&yaml, "yaml", "markdown").expect("Failed to convert");
183
184        assert!(markdown.contains("# Test Spec"));
185    }
186
187    #[test]
188    fn test_convert_markdown_to_yaml() {
189        let spec = Spec {
190            id: "test-spec".to_string(),
191            name: "Test Spec".to_string(),
192            version: "1.0".to_string(),
193            requirements: vec![],
194            design: None,
195            tasks: vec![],
196            metadata: SpecMetadata {
197                author: None,
198                created_at: Utc::now(),
199                updated_at: Utc::now(),
200                phase: SpecPhase::Requirements,
201                status: SpecStatus::Draft,
202            },
203            inheritance: None,
204        };
205
206        let markdown = MarkdownParser::serialize(&spec).expect("Failed to serialize to Markdown");
207        let yaml =
208            FormatConverter::convert(&markdown, "markdown", "yaml").expect("Failed to convert");
209
210        assert!(yaml.contains("id: test-spec"));
211    }
212
213    #[test]
214    fn test_convert_same_format_yaml() {
215        let spec = Spec {
216            id: "test-spec".to_string(),
217            name: "Test Spec".to_string(),
218            version: "1.0".to_string(),
219            requirements: vec![],
220            design: None,
221            tasks: vec![],
222            metadata: SpecMetadata {
223                author: None,
224                created_at: Utc::now(),
225                updated_at: Utc::now(),
226                phase: SpecPhase::Requirements,
227                status: SpecStatus::Draft,
228            },
229            inheritance: None,
230        };
231
232        let yaml = YamlParser::serialize(&spec).expect("Failed to serialize to YAML");
233        let normalized =
234            FormatConverter::convert(&yaml, "yaml", "yaml").expect("Failed to normalize YAML");
235
236        // Should be able to parse both
237        let parsed_original = YamlParser::parse(&yaml).expect("Failed to parse original");
238        let parsed_normalized = YamlParser::parse(&normalized).expect("Failed to parse normalized");
239
240        assert_eq!(parsed_original.id, parsed_normalized.id);
241    }
242
243    #[test]
244    fn test_convert_same_format_markdown() {
245        let spec = Spec {
246            id: "test-spec".to_string(),
247            name: "Test Spec".to_string(),
248            version: "1.0".to_string(),
249            requirements: vec![],
250            design: None,
251            tasks: vec![],
252            metadata: SpecMetadata {
253                author: None,
254                created_at: Utc::now(),
255                updated_at: Utc::now(),
256                phase: SpecPhase::Requirements,
257                status: SpecStatus::Draft,
258            },
259            inheritance: None,
260        };
261
262        let markdown = MarkdownParser::serialize(&spec).expect("Failed to serialize to Markdown");
263        let normalized = FormatConverter::convert(&markdown, "markdown", "markdown")
264            .expect("Failed to normalize Markdown");
265
266        // Should be able to parse both
267        let parsed_original = MarkdownParser::parse(&markdown).expect("Failed to parse original");
268        let parsed_normalized =
269            MarkdownParser::parse(&normalized).expect("Failed to parse normalized");
270
271        assert_eq!(parsed_original.id, parsed_normalized.id);
272    }
273
274    #[test]
275    fn test_convert_invalid_format() {
276        let result = FormatConverter::convert("content", "invalid", "yaml");
277        assert!(result.is_err());
278    }
279
280    #[test]
281    fn test_convert_case_insensitive() {
282        let spec = Spec {
283            id: "test-spec".to_string(),
284            name: "Test Spec".to_string(),
285            version: "1.0".to_string(),
286            requirements: vec![],
287            design: None,
288            tasks: vec![],
289            metadata: SpecMetadata {
290                author: None,
291                created_at: Utc::now(),
292                updated_at: Utc::now(),
293                phase: SpecPhase::Requirements,
294                status: SpecStatus::Draft,
295            },
296            inheritance: None,
297        };
298
299        let yaml = YamlParser::serialize(&spec).expect("Failed to serialize to YAML");
300
301        // Test with uppercase
302        let result1 = FormatConverter::convert(&yaml, "YAML", "MARKDOWN");
303        assert!(result1.is_ok());
304
305        // Test with mixed case
306        let result2 = FormatConverter::convert(&yaml, "YaMl", "MaRkDoWn");
307        assert!(result2.is_ok());
308    }
309}
310
311#[cfg(test)]
312mod property_tests {
313    use super::*;
314    use crate::models::*;
315    use chrono::Utc;
316    use proptest::prelude::*;
317
318    fn arb_spec() -> impl Strategy<Value = Spec> {
319        let valid_id = r"[a-z0-9][a-z0-9\-_]{0,20}";
320        let valid_name = r"[a-zA-Z0-9][a-zA-Z0-9 ]{0,29}";
321        let valid_version = r"[0-9]\.[0-9](\.[0-9])?";
322
323        (valid_id, valid_name, valid_version).prop_map(|(id, name, version)| {
324            let now = Utc::now();
325            Spec {
326                id,
327                name: name.trim().to_string(),
328                version,
329                requirements: vec![],
330                design: None,
331                tasks: vec![],
332                metadata: SpecMetadata {
333                    author: Some("Test".to_string()),
334                    created_at: now,
335                    updated_at: now,
336                    phase: SpecPhase::Requirements,
337                    status: SpecStatus::Draft,
338                },
339                inheritance: None,
340            }
341        })
342    }
343
344    proptest! {
345        /// **Feature: ricecoder-specs, Property 1: Spec Parsing Round-Trip**
346        /// **Validates: Requirements 1.8**
347        ///
348        /// For any valid spec, converting YAML → Markdown → YAML SHALL produce semantically equivalent output.
349        #[test]
350        fn prop_yaml_markdown_yaml_roundtrip(spec in arb_spec()) {
351            // Serialize to YAML
352            let yaml1 = YamlParser::serialize(&spec)
353                .expect("Failed to serialize to YAML");
354
355            // Convert YAML to Markdown
356            let markdown = FormatConverter::yaml_to_markdown(&yaml1)
357                .expect("Failed to convert YAML to Markdown");
358
359            // Convert Markdown back to YAML
360            let yaml2 = FormatConverter::markdown_to_yaml(&markdown)
361                .expect("Failed to convert Markdown to YAML");
362
363            // Parse both YAML versions
364            let parsed1 = YamlParser::parse(&yaml1)
365                .expect("Failed to parse original YAML");
366            let parsed2 = YamlParser::parse(&yaml2)
367                .expect("Failed to parse roundtrip YAML");
368
369            // Verify semantic equivalence
370            prop_assert_eq!(parsed1.id, parsed2.id, "ID should be preserved in YAML→MD→YAML");
371            prop_assert_eq!(parsed1.name, parsed2.name, "Name should be preserved in YAML→MD→YAML");
372            prop_assert_eq!(parsed1.version, parsed2.version, "Version should be preserved in YAML→MD→YAML");
373            prop_assert_eq!(parsed1.metadata.phase, parsed2.metadata.phase, "Phase should be preserved in YAML→MD→YAML");
374            prop_assert_eq!(parsed1.metadata.status, parsed2.metadata.status, "Status should be preserved in YAML→MD→YAML");
375        }
376
377        /// **Feature: ricecoder-specs, Property 1: Spec Parsing Round-Trip**
378        /// **Validates: Requirements 1.8**
379        ///
380        /// For any valid spec, converting Markdown → YAML → Markdown SHALL produce semantically equivalent output.
381        #[test]
382        fn prop_markdown_yaml_markdown_roundtrip(spec in arb_spec()) {
383            // Serialize to Markdown
384            let markdown1 = MarkdownParser::serialize(&spec)
385                .expect("Failed to serialize to Markdown");
386
387            // Convert Markdown to YAML
388            let yaml = FormatConverter::markdown_to_yaml(&markdown1)
389                .expect("Failed to convert Markdown to YAML");
390
391            // Convert YAML back to Markdown
392            let markdown2 = FormatConverter::yaml_to_markdown(&yaml)
393                .expect("Failed to convert YAML to Markdown");
394
395            // Parse both Markdown versions
396            let parsed1 = MarkdownParser::parse(&markdown1)
397                .expect("Failed to parse original Markdown");
398            let parsed2 = MarkdownParser::parse(&markdown2)
399                .expect("Failed to parse roundtrip Markdown");
400
401            // Verify semantic equivalence
402            prop_assert_eq!(parsed1.id, parsed2.id, "ID should be preserved in MD→YAML→MD");
403            prop_assert_eq!(parsed1.name, parsed2.name, "Name should be preserved in MD→YAML→MD");
404            prop_assert_eq!(parsed1.version, parsed2.version, "Version should be preserved in MD→YAML→MD");
405            prop_assert_eq!(parsed1.metadata.phase, parsed2.metadata.phase, "Phase should be preserved in MD→YAML→MD");
406            prop_assert_eq!(parsed1.metadata.status, parsed2.metadata.status, "Status should be preserved in MD→YAML→MD");
407        }
408
409        /// **Feature: ricecoder-specs, Property 1: Spec Parsing Round-Trip**
410        /// **Validates: Requirements 1.8**
411        ///
412        /// For any valid spec, converting between formats SHALL preserve all semantic data.
413        #[test]
414        fn prop_format_conversion_preserves_semantics(spec in arb_spec()) {
415            // Serialize to YAML
416            let yaml = YamlParser::serialize(&spec)
417                .expect("Failed to serialize to YAML");
418
419            // Convert to Markdown
420            let markdown = FormatConverter::yaml_to_markdown(&yaml)
421                .expect("Failed to convert to Markdown");
422
423            // Parse both formats
424            let from_yaml = YamlParser::parse(&yaml)
425                .expect("Failed to parse YAML");
426            let from_markdown = MarkdownParser::parse(&markdown)
427                .expect("Failed to parse Markdown");
428
429            // Verify semantic equivalence
430            prop_assert_eq!(from_yaml.id, from_markdown.id, "ID should match across formats");
431            prop_assert_eq!(from_yaml.name, from_markdown.name, "Name should match across formats");
432            prop_assert_eq!(from_yaml.version, from_markdown.version, "Version should match across formats");
433            prop_assert_eq!(from_yaml.metadata.phase, from_markdown.metadata.phase, "Phase should match across formats");
434            prop_assert_eq!(from_yaml.metadata.status, from_markdown.metadata.status, "Status should match across formats");
435        }
436    }
437}