ricecoder_specs/
parsers.rs

1//! Parsers for YAML and Markdown spec formats
2
3use crate::error::SpecError;
4use crate::models::{Spec, SpecMetadata, SpecPhase, SpecStatus, Task};
5
6/// YAML parser for spec files
7pub struct YamlParser;
8
9impl YamlParser {
10    /// Parse a YAML spec from a string
11    ///
12    /// Supports both plain YAML and YAML with frontmatter (---\n...\n---).
13    /// Frontmatter is optional and will be stripped before parsing.
14    pub fn parse(content: &str) -> Result<Spec, SpecError> {
15        let yaml_content = Self::extract_yaml_content(content);
16        serde_yaml::from_str(yaml_content).map_err(SpecError::YamlError)
17    }
18
19    /// Serialize a spec to YAML
20    ///
21    /// Produces valid YAML without frontmatter markers.
22    pub fn serialize(spec: &Spec) -> Result<String, SpecError> {
23        serde_yaml::to_string(spec).map_err(SpecError::YamlError)
24    }
25
26    /// Extract YAML content from a string that may contain frontmatter
27    ///
28    /// If the content starts with ---, it's treated as frontmatter and extracted.
29    /// Otherwise, the entire content is returned as-is.
30    fn extract_yaml_content(content: &str) -> &str {
31        let trimmed = content.trim_start();
32
33        // Check if content starts with frontmatter delimiter
34        if let Some(after_opening) = trimmed.strip_prefix("---") {
35            // Find the closing delimiter
36            if let Some(closing_pos) = after_opening.find("---") {
37                // Return content after the closing delimiter
38                let yaml_start = 3 + closing_pos + 3;
39                if yaml_start < trimmed.len() {
40                    trimmed[yaml_start..].trim_start()
41                } else {
42                    ""
43                }
44            } else {
45                // No closing delimiter found, treat entire content as YAML
46                trimmed
47            }
48        } else {
49            // No frontmatter, return as-is
50            trimmed
51        }
52    }
53}
54
55/// Markdown parser for spec files
56pub struct MarkdownParser;
57
58impl MarkdownParser {
59    /// Parse a Markdown spec from a string
60    ///
61    /// Extracts structured data from markdown sections using regex patterns.
62    /// Looks for metadata fields in the format: - **Field**: value
63    pub fn parse(content: &str) -> Result<Spec, SpecError> {
64        use regex::Regex;
65
66        let mut spec_id = String::new();
67        let mut spec_name = String::new();
68        let mut spec_version = String::new();
69        let mut author: Option<String> = None;
70        let mut phase = SpecPhase::Requirements;
71        let mut status = SpecStatus::Draft;
72
73        // Extract first H1 as spec name (multiline mode)
74        if let Ok(re) = Regex::new(r"(?m)^#\s+(.+)$") {
75            if let Some(cap) = re.captures(content) {
76                spec_name = cap[1].trim().to_string();
77                spec_id = spec_name.to_lowercase().replace(" ", "-");
78            }
79        }
80
81        // Extract metadata fields
82        if let Ok(re) = Regex::new(r"(?i)-\s*\*\*ID\*\*:\s*([^\n]+)") {
83            if let Some(cap) = re.captures(content) {
84                spec_id = cap[1].trim().to_string();
85            }
86        }
87
88        if let Ok(re) = Regex::new(r"(?i)-\s*\*\*Version\*\*:\s*([^\n]+)") {
89            if let Some(cap) = re.captures(content) {
90                spec_version = cap[1].trim().to_string();
91            }
92        }
93
94        if let Ok(re) = Regex::new(r"(?i)-\s*\*\*Author\*\*:\s*([^\n]+)") {
95            if let Some(cap) = re.captures(content) {
96                let author_str = cap[1].trim().to_string();
97                if !author_str.is_empty() {
98                    author = Some(author_str);
99                }
100            }
101        }
102
103        if let Ok(re) = Regex::new(r"(?i)-\s*\*\*Phase\*\*:\s*([^\n]+)") {
104            if let Some(cap) = re.captures(content) {
105                let phase_str = cap[1].trim().to_lowercase();
106                phase = match phase_str.as_str() {
107                    "discovery" => SpecPhase::Discovery,
108                    "requirements" => SpecPhase::Requirements,
109                    "design" => SpecPhase::Design,
110                    "tasks" => SpecPhase::Tasks,
111                    "execution" => SpecPhase::Execution,
112                    _ => SpecPhase::Requirements,
113                };
114            }
115        }
116
117        if let Ok(re) = Regex::new(r"(?i)-\s*\*\*Status\*\*:\s*([^\n]+)") {
118            if let Some(cap) = re.captures(content) {
119                let status_str = cap[1].trim().to_lowercase();
120                status = match status_str.as_str() {
121                    "draft" => SpecStatus::Draft,
122                    "inreview" => SpecStatus::InReview,
123                    "approved" => SpecStatus::Approved,
124                    "archived" => SpecStatus::Archived,
125                    _ => SpecStatus::Draft,
126                };
127            }
128        }
129
130        Ok(Spec {
131            id: spec_id,
132            name: spec_name,
133            version: spec_version,
134            requirements: vec![],
135            design: None,
136            tasks: vec![],
137            metadata: SpecMetadata {
138                author,
139                created_at: chrono::Utc::now(),
140                updated_at: chrono::Utc::now(),
141                phase,
142                status,
143            },
144            inheritance: None,
145        })
146    }
147
148    /// Serialize a spec to Markdown
149    ///
150    /// Produces markdown with sections for each spec component.
151    pub fn serialize(spec: &Spec) -> Result<String, SpecError> {
152        let mut output = String::new();
153
154        // Header
155        output.push_str(&format!("# {}\n\n", spec.name));
156
157        // Metadata section
158        output.push_str("## Metadata\n\n");
159        output.push_str(&format!("- **ID**: {}\n", spec.id));
160        output.push_str(&format!("- **Version**: {}\n", spec.version));
161        if let Some(author) = &spec.metadata.author {
162            output.push_str(&format!("- **Author**: {}\n", author));
163        }
164        output.push_str(&format!("- **Phase**: {:?}\n", spec.metadata.phase));
165        output.push_str(&format!("- **Status**: {:?}\n", spec.metadata.status));
166        output.push_str(&format!("- **Created**: {}\n", spec.metadata.created_at));
167        output.push_str(&format!("- **Updated**: {}\n\n", spec.metadata.updated_at));
168
169        // Requirements section
170        if !spec.requirements.is_empty() {
171            output.push_str("## Requirements\n\n");
172            for req in &spec.requirements {
173                output.push_str(&format!("### {}: {}\n\n", req.id, req.user_story));
174                output.push_str("#### Acceptance Criteria\n\n");
175                for criterion in &req.acceptance_criteria {
176                    output.push_str(&format!(
177                        "- **{}**: WHEN {} THEN {}\n",
178                        criterion.id, criterion.when, criterion.then
179                    ));
180                }
181                output.push_str(&format!("\n**Priority**: {:?}\n\n", req.priority));
182            }
183        }
184
185        // Design section
186        if let Some(design) = &spec.design {
187            output.push_str("## Design\n\n");
188            output.push_str("### Overview\n\n");
189            output.push_str(&format!("{}\n\n", design.overview));
190
191            output.push_str("### Architecture\n\n");
192            output.push_str(&format!("{}\n\n", design.architecture));
193
194            if !design.components.is_empty() {
195                output.push_str("### Components\n\n");
196                for component in &design.components {
197                    output.push_str(&format!(
198                        "- **{}**: {}\n",
199                        component.name, component.description
200                    ));
201                }
202                output.push('\n');
203            }
204
205            if !design.data_models.is_empty() {
206                output.push_str("### Data Models\n\n");
207                for model in &design.data_models {
208                    output.push_str(&format!("- **{}**: {}\n", model.name, model.description));
209                }
210                output.push('\n');
211            }
212
213            if !design.correctness_properties.is_empty() {
214                output.push_str("### Correctness Properties\n\n");
215                for prop in &design.correctness_properties {
216                    output.push_str(&format!("- **{}**: {}\n", prop.id, prop.description));
217                    if !prop.validates.is_empty() {
218                        output.push_str(&format!("  - Validates: {}\n", prop.validates.join(", ")));
219                    }
220                }
221                output.push('\n');
222            }
223        }
224
225        // Tasks section
226        if !spec.tasks.is_empty() {
227            output.push_str("## Tasks\n\n");
228            Self::serialize_tasks(&mut output, &spec.tasks, 0);
229        }
230
231        Ok(output)
232    }
233
234    /// Helper to serialize tasks recursively
235    fn serialize_tasks(output: &mut String, tasks: &[Task], depth: usize) {
236        for task in tasks {
237            let prefix = "#".repeat(3 + depth);
238            output.push_str(&format!("{} {}: {}\n\n", prefix, task.id, task.description));
239
240            if !task.requirements.is_empty() {
241                output.push_str(&format!(
242                    "{}**Requirements**: {}\n\n",
243                    " ".repeat(depth * 2),
244                    task.requirements.join(", ")
245                ));
246            }
247
248            output.push_str(&format!(
249                "{}**Status**: {:?}\n",
250                " ".repeat(depth * 2),
251                task.status
252            ));
253            output.push_str(&format!(
254                "{}**Optional**: {}\n\n",
255                " ".repeat(depth * 2),
256                task.optional
257            ));
258
259            if !task.subtasks.is_empty() {
260                Self::serialize_tasks(output, &task.subtasks, depth + 1);
261            }
262        }
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::models::*;
270    use chrono::Utc;
271
272    #[test]
273    fn test_yaml_parser_roundtrip() {
274        let spec = Spec {
275            id: "test-spec".to_string(),
276            name: "Test Spec".to_string(),
277            version: "1.0".to_string(),
278            requirements: vec![],
279            design: None,
280            tasks: vec![],
281            metadata: SpecMetadata {
282                author: Some("Test Author".to_string()),
283                created_at: Utc::now(),
284                updated_at: Utc::now(),
285                phase: SpecPhase::Requirements,
286                status: SpecStatus::Draft,
287            },
288            inheritance: None,
289        };
290
291        let yaml = YamlParser::serialize(&spec).expect("Failed to serialize");
292        let parsed = YamlParser::parse(&yaml).expect("Failed to parse");
293
294        assert_eq!(spec.id, parsed.id);
295        assert_eq!(spec.name, parsed.name);
296        assert_eq!(spec.version, parsed.version);
297    }
298
299    #[test]
300    fn test_yaml_parser_with_frontmatter() {
301        let spec = Spec {
302            id: "test-spec".to_string(),
303            name: "Test Spec".to_string(),
304            version: "1.0".to_string(),
305            requirements: vec![],
306            design: None,
307            tasks: vec![],
308            metadata: SpecMetadata {
309                author: Some("Test Author".to_string()),
310                created_at: Utc::now(),
311                updated_at: Utc::now(),
312                phase: SpecPhase::Requirements,
313                status: SpecStatus::Draft,
314            },
315            inheritance: None,
316        };
317
318        let yaml = YamlParser::serialize(&spec).expect("Failed to serialize");
319        let with_frontmatter = format!("---\n# Frontmatter\n---\n{}", yaml);
320        let parsed = YamlParser::parse(&with_frontmatter).expect("Failed to parse");
321
322        assert_eq!(spec.id, parsed.id);
323        assert_eq!(spec.name, parsed.name);
324        assert_eq!(spec.version, parsed.version);
325    }
326
327    #[test]
328    fn test_yaml_parser_frontmatter_extraction() {
329        let content = "---\nmetadata\n---\nid: test\nname: Test";
330        let extracted = YamlParser::extract_yaml_content(content);
331        assert_eq!(extracted, "id: test\nname: Test");
332    }
333
334    #[test]
335    fn test_yaml_parser_no_frontmatter() {
336        let content = "id: test\nname: Test";
337        let extracted = YamlParser::extract_yaml_content(content);
338        assert_eq!(extracted, "id: test\nname: Test");
339    }
340
341    #[test]
342    fn test_yaml_parser_with_whitespace() {
343        let content = "  ---\nmetadata\n---\n  id: test\n  name: Test";
344        let extracted = YamlParser::extract_yaml_content(content);
345        assert_eq!(extracted, "id: test\n  name: Test");
346    }
347}
348
349#[cfg(test)]
350mod markdown_tests {
351    use super::*;
352    use crate::models::*;
353
354    #[test]
355    fn test_markdown_parser_basic_spec() {
356        let markdown = "# Test Spec\n\n## Metadata\n\n- **ID**: test-spec\n- **Version**: 1.0\n- **Author**: Test Author\n- **Phase**: Requirements\n- **Status**: Draft\n";
357
358        let spec = MarkdownParser::parse(markdown).expect("Failed to parse markdown");
359        assert_eq!(spec.id, "test-spec");
360        assert_eq!(spec.name, "Test Spec");
361        assert_eq!(spec.version, "1.0");
362        assert_eq!(spec.metadata.author, Some("Test Author".to_string()));
363        assert_eq!(spec.metadata.phase, SpecPhase::Requirements);
364        assert_eq!(spec.metadata.status, SpecStatus::Draft);
365    }
366
367    #[test]
368    fn test_markdown_parser_missing_explicit_id() {
369        let markdown = "# Test Spec\n\n## Metadata\n\n- **Version**: 1.0\n";
370
371        let spec = MarkdownParser::parse(markdown).expect("Failed to parse markdown");
372        // When no explicit ID is provided, it should be derived from the name
373        assert_eq!(spec.id, "test-spec");
374        assert_eq!(spec.name, "Test Spec");
375        assert_eq!(spec.version, "1.0");
376    }
377
378    #[test]
379    fn test_markdown_serialization_basic() {
380        let spec = Spec {
381            id: "test-spec".to_string(),
382            name: "Test Spec".to_string(),
383            version: "1.0".to_string(),
384            requirements: vec![],
385            design: None,
386            tasks: vec![],
387            metadata: SpecMetadata {
388                author: Some("Test Author".to_string()),
389                created_at: chrono::Utc::now(),
390                updated_at: chrono::Utc::now(),
391                phase: SpecPhase::Requirements,
392                status: SpecStatus::Draft,
393            },
394            inheritance: None,
395        };
396
397        let markdown = MarkdownParser::serialize(&spec).expect("Failed to serialize");
398        assert!(markdown.contains("# Test Spec"));
399        assert!(markdown.contains("test-spec"));
400        assert!(markdown.contains("Test Author"));
401    }
402
403    #[test]
404    fn test_markdown_serialization_with_requirements() {
405        let spec = Spec {
406            id: "test-spec".to_string(),
407            name: "Test Spec".to_string(),
408            version: "1.0".to_string(),
409            requirements: vec![Requirement {
410                id: "REQ-1".to_string(),
411                user_story: "As a user, I want to create tasks".to_string(),
412                acceptance_criteria: vec![AcceptanceCriterion {
413                    id: "AC-1.1".to_string(),
414                    when: "user enters task".to_string(),
415                    then: "task is added".to_string(),
416                }],
417                priority: Priority::Must,
418            }],
419            design: None,
420            tasks: vec![],
421            metadata: SpecMetadata {
422                author: None,
423                created_at: chrono::Utc::now(),
424                updated_at: chrono::Utc::now(),
425                phase: SpecPhase::Requirements,
426                status: SpecStatus::Draft,
427            },
428            inheritance: None,
429        };
430
431        let markdown = MarkdownParser::serialize(&spec).expect("Failed to serialize");
432        assert!(markdown.contains("## Requirements"));
433        assert!(markdown.contains("REQ-1"));
434        assert!(markdown.contains("As a user, I want to create tasks"));
435        assert!(markdown.contains("AC-1.1"));
436        assert!(markdown.contains("WHEN user enters task THEN task is added"));
437    }
438
439    #[test]
440    fn test_markdown_serialization_with_design() {
441        let spec = Spec {
442            id: "test-spec".to_string(),
443            name: "Test Spec".to_string(),
444            version: "1.0".to_string(),
445            requirements: vec![],
446            design: Some(Design {
447                overview: "System overview".to_string(),
448                architecture: "Layered architecture".to_string(),
449                components: vec![Component {
450                    name: "ComponentA".to_string(),
451                    description: "First component".to_string(),
452                }],
453                data_models: vec![DataModel {
454                    name: "Model1".to_string(),
455                    description: "First model".to_string(),
456                }],
457                correctness_properties: vec![Property {
458                    id: "PROP-1".to_string(),
459                    description: "Property description".to_string(),
460                    validates: vec!["REQ-1".to_string()],
461                }],
462            }),
463            tasks: vec![],
464            metadata: SpecMetadata {
465                author: None,
466                created_at: chrono::Utc::now(),
467                updated_at: chrono::Utc::now(),
468                phase: SpecPhase::Design,
469                status: SpecStatus::Draft,
470            },
471            inheritance: None,
472        };
473
474        let markdown = MarkdownParser::serialize(&spec).expect("Failed to serialize");
475        assert!(markdown.contains("## Design"));
476        assert!(markdown.contains("System overview"));
477        assert!(markdown.contains("Layered architecture"));
478        assert!(markdown.contains("ComponentA"));
479        assert!(markdown.contains("Model1"));
480        assert!(markdown.contains("PROP-1"));
481    }
482
483    #[test]
484    fn test_markdown_serialization_with_tasks() {
485        let spec = Spec {
486            id: "test-spec".to_string(),
487            name: "Test Spec".to_string(),
488            version: "1.0".to_string(),
489            requirements: vec![],
490            design: None,
491            tasks: vec![Task {
492                id: "1".to_string(),
493                description: "Main task".to_string(),
494                subtasks: vec![Task {
495                    id: "1.1".to_string(),
496                    description: "Subtask".to_string(),
497                    subtasks: vec![],
498                    requirements: vec!["REQ-1".to_string()],
499                    status: TaskStatus::NotStarted,
500                    optional: false,
501                }],
502                requirements: vec![],
503                status: TaskStatus::InProgress,
504                optional: false,
505            }],
506            metadata: SpecMetadata {
507                author: None,
508                created_at: chrono::Utc::now(),
509                updated_at: chrono::Utc::now(),
510                phase: SpecPhase::Tasks,
511                status: SpecStatus::Draft,
512            },
513            inheritance: None,
514        };
515
516        let markdown = MarkdownParser::serialize(&spec).expect("Failed to serialize");
517        assert!(markdown.contains("## Tasks"));
518        assert!(markdown.contains("### 1: Main task"));
519        assert!(markdown.contains("#### 1.1: Subtask"));
520    }
521}
522
523#[cfg(test)]
524mod property_tests {
525    use super::*;
526    use crate::models::*;
527    use chrono::Utc;
528    use proptest::prelude::*;
529
530    // Helper function to generate arbitrary Spec values with valid IDs
531    fn arb_spec() -> impl Strategy<Value = Spec> {
532        // Generate valid spec IDs: alphanumeric, hyphens, underscores
533        let valid_id = r"[a-z0-9][a-z0-9\-_]{0,20}";
534        // Generate valid names: must have at least one non-space character
535        let valid_name = r"[a-zA-Z0-9][a-zA-Z0-9 ]{0,29}";
536        let valid_version = r"[0-9]\.[0-9](\.[0-9])?";
537
538        (valid_id, valid_name, valid_version).prop_map(|(id, name, version)| {
539            let now = Utc::now();
540            Spec {
541                id,
542                name: name.trim().to_string(), // Trim whitespace for consistency
543                version,
544                requirements: vec![],
545                design: None,
546                tasks: vec![],
547                metadata: SpecMetadata {
548                    author: Some("Test".to_string()),
549                    created_at: now,
550                    updated_at: now,
551                    phase: SpecPhase::Requirements,
552                    status: SpecStatus::Draft,
553                },
554                inheritance: None,
555            }
556        })
557    }
558
559    proptest! {
560        /// **Feature: ricecoder-specs, Property 1: Spec Parsing Round-Trip**
561        /// **Validates: Requirements 1.1, 1.2, 1.4**
562        ///
563        /// For any valid spec, parsing and serializing SHALL produce semantically equivalent output.
564        #[test]
565        fn prop_yaml_roundtrip_preserves_spec(spec in arb_spec()) {
566            // Serialize the spec to YAML
567            let yaml = YamlParser::serialize(&spec)
568                .expect("Failed to serialize spec");
569
570            // Parse it back
571            let parsed = YamlParser::parse(&yaml)
572                .expect("Failed to parse spec");
573
574            // Verify semantic equivalence
575            prop_assert_eq!(spec.id, parsed.id, "ID should be preserved");
576            prop_assert_eq!(spec.name, parsed.name, "Name should be preserved");
577            prop_assert_eq!(spec.version, parsed.version, "Version should be preserved");
578            prop_assert_eq!(spec.requirements.len(), parsed.requirements.len(), "Requirements count should be preserved");
579            prop_assert_eq!(spec.tasks.len(), parsed.tasks.len(), "Tasks count should be preserved");
580            prop_assert_eq!(spec.metadata.phase, parsed.metadata.phase, "Phase should be preserved");
581            prop_assert_eq!(spec.metadata.status, parsed.metadata.status, "Status should be preserved");
582        }
583
584        /// **Feature: ricecoder-specs, Property 1: Spec Parsing Round-Trip**
585        /// **Validates: Requirements 1.1, 1.2, 1.4**
586        ///
587        /// For any valid spec with frontmatter, parsing and serializing SHALL produce semantically equivalent output.
588        #[test]
589        fn prop_yaml_roundtrip_with_frontmatter(spec in arb_spec()) {
590            // Serialize the spec to YAML
591            let yaml = YamlParser::serialize(&spec)
592                .expect("Failed to serialize spec");
593
594            // Add frontmatter
595            let with_frontmatter = format!("---\n# Metadata\n---\n{}", yaml);
596
597            // Parse it back
598            let parsed = YamlParser::parse(&with_frontmatter)
599                .expect("Failed to parse spec with frontmatter");
600
601            // Verify semantic equivalence
602            prop_assert_eq!(spec.id, parsed.id, "ID should be preserved with frontmatter");
603            prop_assert_eq!(spec.name, parsed.name, "Name should be preserved with frontmatter");
604            prop_assert_eq!(spec.version, parsed.version, "Version should be preserved with frontmatter");
605        }
606
607        /// **Feature: ricecoder-specs, Property 1: Spec Parsing Round-Trip**
608        /// **Validates: Requirements 1.1, 1.2, 1.4**
609        ///
610        /// For any valid spec, markdown serialization and parsing SHALL produce semantically equivalent output.
611        #[test]
612        fn prop_markdown_roundtrip_preserves_spec(spec in arb_spec()) {
613            // Serialize the spec to Markdown
614            let markdown = MarkdownParser::serialize(&spec)
615                .expect("Failed to serialize spec to markdown");
616
617            // Parse it back
618            let parsed = MarkdownParser::parse(&markdown)
619                .expect("Failed to parse markdown spec");
620
621            // Verify semantic equivalence
622            prop_assert_eq!(spec.id, parsed.id, "ID should be preserved in markdown roundtrip");
623            prop_assert_eq!(spec.name, parsed.name, "Name should be preserved in markdown roundtrip");
624            prop_assert_eq!(spec.version, parsed.version, "Version should be preserved in markdown roundtrip");
625            prop_assert_eq!(spec.metadata.phase, parsed.metadata.phase, "Phase should be preserved in markdown roundtrip");
626            prop_assert_eq!(spec.metadata.status, parsed.metadata.status, "Status should be preserved in markdown roundtrip");
627        }
628    }
629}