1use crate::error::SpecError;
4use crate::models::{Spec, SpecMetadata, SpecPhase, SpecStatus, Task};
5
6pub struct YamlParser;
8
9impl YamlParser {
10 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 pub fn serialize(spec: &Spec) -> Result<String, SpecError> {
23 serde_yaml::to_string(spec).map_err(SpecError::YamlError)
24 }
25
26 fn extract_yaml_content(content: &str) -> &str {
31 let trimmed = content.trim_start();
32
33 if let Some(after_opening) = trimmed.strip_prefix("---") {
35 if let Some(closing_pos) = after_opening.find("---") {
37 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 trimmed
47 }
48 } else {
49 trimmed
51 }
52 }
53}
54
55pub struct MarkdownParser;
57
58impl MarkdownParser {
59 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 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 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 pub fn serialize(spec: &Spec) -> Result<String, SpecError> {
152 let mut output = String::new();
153
154 output.push_str(&format!("# {}\n\n", spec.name));
156
157 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 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 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 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 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 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 fn arb_spec() -> impl Strategy<Value = Spec> {
532 let valid_id = r"[a-z0-9][a-z0-9\-_]{0,20}";
534 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(), 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 #[test]
565 fn prop_yaml_roundtrip_preserves_spec(spec in arb_spec()) {
566 let yaml = YamlParser::serialize(&spec)
568 .expect("Failed to serialize spec");
569
570 let parsed = YamlParser::parse(&yaml)
572 .expect("Failed to parse spec");
573
574 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 #[test]
589 fn prop_yaml_roundtrip_with_frontmatter(spec in arb_spec()) {
590 let yaml = YamlParser::serialize(&spec)
592 .expect("Failed to serialize spec");
593
594 let with_frontmatter = format!("---\n# Metadata\n---\n{}", yaml);
596
597 let parsed = YamlParser::parse(&with_frontmatter)
599 .expect("Failed to parse spec with frontmatter");
600
601 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 #[test]
612 fn prop_markdown_roundtrip_preserves_spec(spec in arb_spec()) {
613 let markdown = MarkdownParser::serialize(&spec)
615 .expect("Failed to serialize spec to markdown");
616
617 let parsed = MarkdownParser::parse(&markdown)
619 .expect("Failed to parse markdown spec");
620
621 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}