1use crate::types::*;
7use pulldown_cmark::{Event, Parser, Tag, TagEnd};
8use std::collections::HashMap;
9use std::ops::Range;
10use thiserror::Error;
11
12#[derive(Debug, Error)]
14pub enum ParseError {
15 #[error("Missing required field '{field}' in {block} block")]
16 MissingField { block: String, field: String },
17
18 #[error("Invalid attribute value '{value}' for '{attribute}'")]
19 InvalidAttribute { attribute: String, value: String },
20
21 #[error("Unclosed directive block '{block}' starting at line {line}")]
22 UnclosedBlock { block: String, line: usize },
23
24 #[error("Duplicate block type '{block_type}' (only one allowed)")]
25 DuplicateBlock { block_type: String },
26
27 #[error("YAML parse error in {block} block: {source}")]
28 YamlError {
29 block: String,
30 #[source]
31 source: serde_yaml::Error,
32 },
33
34 #[error("Invalid hint level: {0}")]
35 InvalidHintLevel(String),
36
37 #[error("Unknown exercise type. Must contain either '::: exercise' or '::: usecase'")]
38 UnknownExerciseType,
39}
40
41pub type ParseResult<T> = Result<T, ParseError>;
43
44#[derive(Debug)]
46struct Directive {
47 name: String,
49
50 attributes: HashMap<String, String>,
52
53 line: usize,
55}
56
57pub fn parse_exercise(markdown: &str) -> ParseResult<ParsedExercise> {
59 let excluded = find_excluded_ranges(markdown);
64
65 if contains_directive(markdown, "usecase", &excluded) {
67 return parse_usecase_exercise(markdown, excluded).map(ParsedExercise::UseCase);
68 }
69
70 if contains_directive(markdown, "exercise", &excluded) {
72 return parse_code_exercise(markdown, excluded).map(ParsedExercise::Code);
73 }
74
75 Err(ParseError::UnknownExerciseType)
77}
78
79fn contains_directive(markdown: &str, directive: &str, excluded: &[Range<usize>]) -> bool {
81 let pattern = format!("::: {}", directive);
82 let mut offset = 0;
83
84 for line in markdown.lines() {
85 let line_len = line.len() + 1; let range = offset..(offset + line.len());
87 offset += line_len;
88
89 if line.trim().starts_with(&pattern) {
90 if !is_range_excluded(&range, excluded) {
91 return true;
92 }
93 }
94 }
95 false
96}
97
98fn parse_code_exercise(markdown: &str, excluded_ranges: Vec<Range<usize>>) -> ParseResult<Exercise> {
100 let mut exercise = Exercise::default();
101 let mut current_directive: Option<Directive> = None;
102 let mut block_content = String::new();
103 let mut description_buffer = String::new();
104 let mut in_description = true;
105
106 let mut current_offset = 0;
107 for (line_num, line_raw) in markdown.split_inclusive('\n').enumerate() {
108 let line_number = line_num + 1;
109 let line_len = line_raw.len();
110 let line_range = current_offset..(current_offset + line_len);
111
112 let line = line_raw.trim_end_matches(['\n', '\r']);
113 let is_excluded = is_range_excluded(&line_range, &excluded_ranges);
114 current_offset += line_len;
115
116 if !is_excluded {
117 if let Some(directive) = parse_directive_start(line, line_number) {
118 if let Some(prev_directive) = current_directive.take() {
119 process_code_block(&mut exercise, &prev_directive, &block_content)?;
120 } else if in_description && directive.name != "exercise" {
121 exercise.description = description_buffer.trim().to_string();
122 in_description = false;
123 }
124
125 current_directive = Some(directive);
126 block_content.clear();
127 continue;
128 }
129
130 if line.trim() == ":::" {
131 if let Some(directive) = current_directive.take() {
132 process_code_block(&mut exercise, &directive, &block_content)?;
133 block_content.clear();
134 }
135 continue;
136 }
137 }
138
139 if current_directive.is_some() {
140 block_content.push_str(line_raw);
141 } else if in_description {
142 if exercise.title.is_none() && line.starts_with('#') && !is_excluded {
143 let title = line.trim_start_matches('#').trim();
144 if !title.is_empty() {
145 exercise.title = Some(title.to_string());
146 continue;
147 }
148 }
149 description_buffer.push_str(line_raw);
150 }
151 }
152
153 if let Some(directive) = current_directive {
154 return Err(ParseError::UnclosedBlock {
155 block: directive.name,
156 line: directive.line,
157 });
158 }
159
160 if in_description && !description_buffer.is_empty() {
161 exercise.description = description_buffer.trim().to_string();
162 }
163
164 Ok(exercise)
165}
166
167fn parse_usecase_exercise(markdown: &str, excluded_ranges: Vec<Range<usize>>) -> ParseResult<UseCaseExercise> {
169 let mut exercise = UseCaseExercise::default();
170 let mut current_directive: Option<Directive> = None;
171 let mut block_content = String::new();
172 let mut description_buffer = String::new();
173 let mut in_description = true;
174
175 let mut current_offset = 0;
176 for (line_num, line_raw) in markdown.split_inclusive('\n').enumerate() {
177 let line_number = line_num + 1;
178 let line_len = line_raw.len();
179 let line_range = current_offset..(current_offset + line_len);
180
181 let line = line_raw.trim_end_matches(['\n', '\r']);
182 let is_excluded = is_range_excluded(&line_range, &excluded_ranges);
183 current_offset += line_len;
184
185 if !is_excluded {
186 if let Some(directive) = parse_directive_start(line, line_number) {
187 if let Some(prev_directive) = current_directive.take() {
188 process_usecase_block(&mut exercise, &prev_directive, &block_content)?;
189 } else if in_description && directive.name != "usecase" {
190 exercise.description = description_buffer.trim().to_string();
191 in_description = false;
192 }
193
194 current_directive = Some(directive);
195 block_content.clear();
196 continue;
197 }
198
199 if line.trim() == ":::" {
200 if let Some(directive) = current_directive.take() {
201 process_usecase_block(&mut exercise, &directive, &block_content)?;
202 block_content.clear();
203 }
204 continue;
205 }
206 }
207
208 if current_directive.is_some() {
209 block_content.push_str(line_raw);
210 } else if in_description {
211 if exercise.title.is_none() && line.starts_with('#') && !is_excluded {
212 let title = line.trim_start_matches('#').trim();
213 if !title.is_empty() {
214 exercise.title = Some(title.to_string());
215 continue;
216 }
217 }
218 description_buffer.push_str(line_raw);
219 }
220 }
221
222 if let Some(directive) = current_directive {
223 return Err(ParseError::UnclosedBlock {
224 block: directive.name,
225 line: directive.line,
226 });
227 }
228
229 if in_description && !description_buffer.is_empty() {
230 exercise.description = description_buffer.trim().to_string();
231 }
232
233 Ok(exercise)
234}
235
236fn process_code_block(exercise: &mut Exercise, directive: &Directive, content: &str) -> ParseResult<()> {
238 match directive.name.as_str() {
239 "exercise" => parse_exercise_block(exercise, content)?,
240 "objectives" => parse_objectives_block(&mut exercise.objectives, content)?,
241 "discussion" => parse_discussion_block(exercise, content)?,
242 "starter" => parse_starter_block(exercise, &directive.attributes, content)?,
243 "hint" => parse_hint_block(&mut exercise.hints, &directive.attributes, content)?,
244 "solution" => parse_solution_block(exercise, &directive.attributes, content)?,
245 "tests" => parse_tests_block(exercise, &directive.attributes, content)?,
246 "reflection" => parse_reflection_block(exercise, content)?,
247 _ => {
248 }
250 }
251 Ok(())
252}
253
254fn process_usecase_block(exercise: &mut UseCaseExercise, directive: &Directive, content: &str) -> ParseResult<()> {
256 match directive.name.as_str() {
257 "usecase" => parse_usecase_meta_block(exercise, content)?,
258 "scenario" => parse_scenario_block(exercise, &directive.attributes, content)?,
259 "prompt" => parse_prompt_block(exercise, content)?,
260 "evaluation" => parse_evaluation_block(exercise, content)?,
261 "sample-answer" => parse_sample_answer_block(exercise, &directive.attributes, content)?,
262 "context" => parse_context_block(exercise, content)?,
263 "objectives" => parse_objectives_block(&mut exercise.objectives, content)?,
264 "hint" => parse_hint_block(&mut exercise.hints, &directive.attributes, content)?,
265 _ => {
266 }
268 }
269 Ok(())
270}
271
272fn parse_objectives_block(objectives_opt: &mut Option<Objectives>, content: &str) -> ParseResult<()> {
275 let yaml: serde_yaml::Value =
276 serde_yaml::from_str(content).map_err(|e| ParseError::YamlError {
277 block: "objectives".to_string(),
278 source: e,
279 })?;
280
281 let mut objectives = Objectives::default();
282
283 if let Some(thinking) = yaml.get("thinking").and_then(|v| v.as_sequence()) {
284 objectives.thinking = thinking
285 .iter()
286 .filter_map(|v| v.as_str())
287 .map(String::from)
288 .collect();
289 }
290
291 if let Some(doing) = yaml.get("doing").and_then(|v| v.as_sequence()) {
292 objectives.doing = doing
293 .iter()
294 .filter_map(|v| v.as_str())
295 .map(String::from)
296 .collect();
297 }
298
299 *objectives_opt = Some(objectives);
300 Ok(())
301}
302
303fn parse_hint_block(
304 hints: &mut Vec<Hint>,
305 attrs: &HashMap<String, String>,
306 content: &str,
307) -> ParseResult<()> {
308 let level = attrs
309 .get("level")
310 .ok_or_else(|| ParseError::MissingField {
311 block: "hint".to_string(),
312 field: "level".to_string(),
313 })?
314 .parse::<u8>()
315 .map_err(|_| ParseError::InvalidHintLevel(attrs.get("level").unwrap().clone()))?;
316
317 let title = attrs.get("title").cloned();
318
319 hints.push(Hint {
320 level,
321 title,
322 content: content.trim().to_string(),
323 });
324
325 hints.sort_by_key(|h| h.level);
326
327 Ok(())
328}
329
330fn parse_exercise_block(exercise: &mut Exercise, content: &str) -> ParseResult<()> {
333 let yaml: serde_yaml::Value =
334 serde_yaml::from_str(content).map_err(|e| ParseError::YamlError {
335 block: "exercise".to_string(),
336 source: e,
337 })?;
338
339 if let Some(id) = yaml.get("id").and_then(|v| v.as_str()) {
340 exercise.metadata.id = id.to_string();
341 } else {
342 return Err(ParseError::MissingField {
343 block: "exercise".to_string(),
344 field: "id".to_string(),
345 });
346 }
347
348 if let Some(difficulty) = yaml.get("difficulty").and_then(|v| v.as_str()) {
349 exercise.metadata.difficulty =
350 difficulty
351 .parse()
352 .map_err(|_| ParseError::InvalidAttribute {
353 attribute: "difficulty".to_string(),
354 value: difficulty.to_string(),
355 })?;
356 }
357
358 if let Some(time_value) = yaml.get("time") {
359 if let Some(time_str) = time_value.as_str() {
360 exercise.metadata.time_minutes = parse_time_string(time_str);
361 } else if let Some(time_int) = time_value.as_u64() {
362 exercise.metadata.time_minutes = Some(time_int as u32);
363 }
364 }
365
366 if let Some(prereqs) = yaml.get("prerequisites") {
367 if let Some(arr) = prereqs.as_sequence() {
368 exercise.metadata.prerequisites = arr
369 .iter()
370 .filter_map(|v| v.as_str())
371 .map(String::from)
372 .collect();
373 }
374 }
375
376 Ok(())
377}
378
379fn parse_discussion_block(exercise: &mut Exercise, content: &str) -> ParseResult<()> {
380 let items = parse_markdown_list(content);
381 if !items.is_empty() {
382 exercise.discussion = Some(items);
383 }
384 Ok(())
385}
386
387fn parse_starter_block(
388 exercise: &mut Exercise,
389 attrs: &HashMap<String, String>,
390 content: &str,
391) -> ParseResult<()> {
392 let (language_raw, code) = extract_code_block(content);
393
394 if code.trim().is_empty() {
395 return Ok(());
396 }
397
398 let mut filename = attrs.get("file").cloned();
399 let mut language = attrs.get("language").cloned();
400
401 let mut info_opt = language_raw;
402 if info_opt.is_none() {
403 for line in content.lines() {
404 let t = line.trim();
405 if t.starts_with("```") {
406 let info = t.trim_start_matches('`').trim().to_string();
407 if !info.is_empty() { info_opt = Some(info); }
408 break;
409 }
410 }
411 }
412
413 if let Some(info) = info_opt {
414 let (lang_clean, fence_attrs) = parse_fence_info(&info);
415 if language.is_none() && !lang_clean.is_empty() {
416 language = Some(lang_clean);
417 }
418 if filename.is_none() {
419 if let Some(f) = fence_attrs.get("filename") {
420 filename = Some(f.clone());
421 } else if let Some(f) = fence_attrs.get("file") {
422 filename = Some(f.clone());
423 }
424 }
425 }
426
427 exercise.starter = Some(StarterCode {
428 filename,
429 language: language.unwrap_or_else(|| "rust".to_string()),
430 code,
431 });
432
433 Ok(())
434}
435
436fn parse_solution_block(exercise: &mut Exercise, attrs: &HashMap<String, String>, content: &str) -> ParseResult<()> {
437 let (language, code) = extract_code_block(content);
438 let explanation = extract_explanation(content);
439
440 let mut sol = Solution {
441 code,
442 language: language.unwrap_or_else(|| "rust".to_string()),
443 explanation,
444 ..Default::default()
445 };
446
447 if sol.code.trim().is_empty() {
448 return Ok(());
449 }
450
451 if let Some(reveal) = attrs.get("reveal").map(|s| s.to_lowercase()) {
452 sol.reveal = match reveal.as_str() {
453 "always" => SolutionReveal::Always,
454 "never" => SolutionReveal::Never,
455 _ => SolutionReveal::OnDemand,
456 };
457 }
458
459 exercise.solution = Some(sol);
460 Ok(())
461}
462
463fn parse_tests_block(
464 exercise: &mut Exercise,
465 attrs: &HashMap<String, String>,
466 content: &str,
467) -> ParseResult<()> {
468 let (language_raw, code) = extract_code_block(content);
469
470 if code.trim().is_empty() {
471 return Ok(());
472 }
473
474 let mode = attrs
475 .get("mode")
476 .map(|m| m.parse().unwrap_or(TestMode::Playground))
477 .unwrap_or(TestMode::Playground);
478
479 let mut language = attrs.get("language").cloned();
480 if let Some(info) = language_raw {
481 let (lang_clean, _fa) = parse_fence_info(&info);
482 if language.is_none() && !lang_clean.is_empty() {
483 language = Some(lang_clean);
484 }
485 }
486
487 exercise.tests = Some(TestBlock { language: language.unwrap_or_else(|| "rust".to_string()), code, mode });
488 Ok(())
489}
490
491fn parse_reflection_block(exercise: &mut Exercise, content: &str) -> ParseResult<()> {
492 let items = parse_markdown_list(content);
493 if !items.is_empty() {
494 exercise.reflection = Some(items);
495 }
496 Ok(())
497}
498
499fn parse_usecase_meta_block(exercise: &mut UseCaseExercise, content: &str) -> ParseResult<()> {
502 let yaml: serde_yaml::Value =
503 serde_yaml::from_str(content).map_err(|e| ParseError::YamlError {
504 block: "usecase".to_string(),
505 source: e,
506 })?;
507
508 if let Some(id) = yaml.get("id").and_then(|v| v.as_str()) {
509 exercise.metadata.id = id.to_string();
510 } else {
511 return Err(ParseError::MissingField {
512 block: "usecase".to_string(),
513 field: "id".to_string(),
514 });
515 }
516
517 if let Some(difficulty) = yaml.get("difficulty").and_then(|v| v.as_str()) {
518 exercise.metadata.difficulty = difficulty.parse().unwrap_or_default();
519 }
520
521 if let Some(domain) = yaml.get("domain").and_then(|v| v.as_str()) {
522 exercise.metadata.domain = domain.parse().unwrap_or_default();
523 }
524
525 if let Some(time_value) = yaml.get("time") {
526 if let Some(time_str) = time_value.as_str() {
527 exercise.metadata.time_minutes = parse_time_string(time_str);
528 } else if let Some(time_int) = time_value.as_u64() {
529 exercise.metadata.time_minutes = Some(time_int as u32);
530 }
531 }
532
533 if let Some(prereqs) = yaml.get("prerequisites") {
534 if let Some(arr) = prereqs.as_sequence() {
535 exercise.metadata.prerequisites = arr
536 .iter()
537 .filter_map(|v| v.as_str())
538 .map(String::from)
539 .collect();
540 }
541 }
542
543 Ok(())
544}
545
546fn parse_scenario_block(
547 exercise: &mut UseCaseExercise,
548 _attrs: &HashMap<String, String>,
549 content: &str,
550) -> ParseResult<()> {
551 let mut scenario = Scenario::default();
552
553 let mut yaml_lines = Vec::new();
556 let mut content_lines = Vec::new();
557 let mut in_yaml = true;
558 let mut in_yaml_list = false;
559
560 for line in content.lines() {
561 if in_yaml {
562 let trimmed = line.trim();
563 if trimmed.contains(':') && !trimmed.starts_with('-') && !trimmed.starts_with('#') {
565 yaml_lines.push(line);
566 in_yaml_list = trimmed.ends_with(':');
567 } else if in_yaml_list && trimmed.starts_with('-') {
568 yaml_lines.push(line);
569 } else if trimmed.is_empty() && yaml_lines.is_empty() {
570 } else {
572 in_yaml = false;
574 in_yaml_list = false;
575 content_lines.push(line);
576 }
577 } else {
578 content_lines.push(line);
579 }
580 }
581
582 if !yaml_lines.is_empty() {
583 let yaml_str = yaml_lines.join("\n");
584 if let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&yaml_str) {
585 if let Some(org) = yaml.get("organization").and_then(|v| v.as_str()) {
586 scenario.organization = Some(org.to_string());
587 }
588 if let Some(constraints) = yaml.get("constraints").and_then(|v| v.as_sequence()) {
589 scenario.constraints = constraints.iter().filter_map(|v| v.as_str()).map(String::from).collect();
590 }
591 }
592 }
593
594 scenario.content = content_lines.join("\n").trim().to_string();
595 exercise.scenario = scenario;
596 Ok(())
597}
598
599fn parse_prompt_block(exercise: &mut UseCaseExercise, content: &str) -> ParseResult<()> {
600 let mut prompt = UseCasePrompt::default();
602
603 let mut yaml_lines = Vec::new();
605 let mut content_lines = Vec::new();
606 let mut in_yaml = true;
607 let mut in_yaml_list = false;
608
609 for line in content.lines() {
610 if in_yaml {
611 let trimmed = line.trim();
612 if trimmed.contains(':') && !trimmed.starts_with('-') && !trimmed.starts_with('#') {
613 yaml_lines.push(line);
614 in_yaml_list = trimmed.ends_with(':');
615 } else if in_yaml_list && trimmed.starts_with('-') {
616 yaml_lines.push(line);
617 } else if trimmed.is_empty() && yaml_lines.is_empty() {
618 } else {
620 in_yaml = false;
621 in_yaml_list = false;
622 content_lines.push(line);
623 }
624 } else {
625 content_lines.push(line);
626 }
627 }
628
629 if !yaml_lines.is_empty() {
630 let yaml_str = yaml_lines.join("\n");
631 if let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Value>(&yaml_str) {
632 if let Some(aspects) = yaml.get("aspects").and_then(|v| v.as_sequence()) {
633 prompt.aspects = aspects.iter().filter_map(|v| v.as_str()).map(String::from).collect();
634 }
635 }
636 }
637
638 prompt.prompt = content_lines.join("\n").trim().to_string();
639 exercise.prompt = prompt;
640 Ok(())
641}
642
643fn parse_evaluation_block(exercise: &mut UseCaseExercise, content: &str) -> ParseResult<()> {
644 let yaml: serde_yaml::Value = serde_yaml::from_str(content).map_err(|e| ParseError::YamlError {
646 block: "evaluation".to_string(),
647 source: e,
648 })?;
649
650 let mut eval = EvaluationCriteria::default();
651
652 if let Some(min) = yaml.get("min_words").and_then(|v| v.as_u64()) {
653 eval.min_words = Some(min as u32);
654 }
655 if let Some(max) = yaml.get("max_words").and_then(|v| v.as_u64()) {
656 eval.max_words = Some(max as u32);
657 }
658 if let Some(pass) = yaml.get("pass_threshold").and_then(|v| v.as_f64()) {
659 eval.pass_threshold = Some(pass as f32);
660 }
661
662 if let Some(pts) = yaml.get("key_points").and_then(|v| v.as_sequence()) {
663 eval.key_points = pts.iter().filter_map(|v| v.as_str()).map(String::from).collect();
664 }
665
666 if let Some(crit) = yaml.get("criteria").and_then(|v| v.as_sequence()) {
667 for c in crit {
668 let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("Unknown").to_string();
669 let weight = c.get("weight").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
670 let desc = c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
671
672 eval.criteria.push(Criterion { name, weight, description: desc });
673 }
674 }
675
676 exercise.evaluation = eval;
677 Ok(())
678}
679
680fn parse_sample_answer_block(
681 exercise: &mut UseCaseExercise,
682 attrs: &HashMap<String, String>,
683 content: &str
684) -> ParseResult<()> {
685 let mut answer = SampleAnswer {
689 content: String::new(),
690 expected_score: None,
691 reveal: SolutionReveal::OnDemand, };
693
694 if let Some(reveal) = attrs.get("reveal").map(|s| s.to_lowercase()) {
696 answer.reveal = match reveal.as_str() {
697 "always" => SolutionReveal::Always,
698 "never" => SolutionReveal::Never,
699 _ => SolutionReveal::OnDemand,
700 };
701 }
702
703 let lines: Vec<&str> = content.lines().collect();
705 let mut start_idx = 0;
706
707 for (i, line) in lines.iter().enumerate() {
708 if line.trim().starts_with("expected_score:") {
709 if let Some(val_str) = line.split(':').nth(1) {
710 if let Ok(val) = val_str.trim().parse::<f32>() {
711 answer.expected_score = Some(val);
712 }
713 }
714 start_idx = i + 1;
715 } else if line.trim().is_empty() {
716 if start_idx == i { start_idx += 1; }
718 } else {
719 break;
720 }
721 }
722
723 answer.content = lines[start_idx..].join("\n").trim().to_string();
724
725 exercise.sample_answer = Some(answer);
726 Ok(())
727}
728
729fn parse_context_block(exercise: &mut UseCaseExercise, content: &str) -> ParseResult<()> {
730 exercise.context = Some(content.trim().to_string());
731 Ok(())
732}
733
734
735fn find_excluded_ranges(markdown: &str) -> Vec<Range<usize>> {
738 let mut ranges = Vec::new();
739 let parser = Parser::new(markdown).into_offset_iter();
740 let mut block_start: Option<usize> = None;
741
742 for (event, range) in parser {
743 match event {
744 Event::Start(Tag::CodeBlock(_)) | Event::Start(Tag::HtmlBlock) => {
745 if block_start.is_none() {
746 block_start = Some(range.start);
747 }
748 }
749 Event::End(TagEnd::CodeBlock) | Event::End(TagEnd::HtmlBlock) => {
750 if let Some(start) = block_start {
751 ranges.push(start..range.end);
752 block_start = None;
753 }
754 }
755 Event::Code(_) | Event::Html(_) => {
756 if block_start.is_none() {
757 ranges.push(range);
758 }
759 }
760 _ => {}
761 }
762 }
763 ranges
764}
765
766fn is_range_excluded(line_range: &Range<usize>, excluded: &[Range<usize>]) -> bool {
767 for range in excluded {
768 if range.contains(&line_range.start) {
769 return true;
770 }
771 if range.start <= line_range.start && range.end >= line_range.end {
772 return true;
773 }
774 }
775 false
776}
777
778fn parse_directive_start(line: &str, line_number: usize) -> Option<Directive> {
779 let trimmed = line.trim();
780 if !trimmed.starts_with(":::") {
781 return None;
782 }
783 let rest = trimmed[3..].trim();
784 if rest.is_empty() || rest.starts_with(":::") {
785 return None;
786 }
787
788 let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
789 let name = parts.next()?.to_string();
790 let attrs_str = parts.next().unwrap_or("");
791
792 let attributes = parse_inline_attributes(attrs_str);
793
794 Some(Directive {
795 name,
796 attributes,
797 line: line_number,
798 })
799}
800
801fn parse_inline_attributes(attrs_str: &str) -> HashMap<String, String> {
802 let mut attrs = HashMap::new();
803 let mut remaining = attrs_str.trim();
804
805 while !remaining.is_empty() {
806 remaining = remaining.trim_start();
807 if remaining.is_empty() { break; }
808
809 let key_end = remaining
810 .find(|c: char| c == '=' || c.is_whitespace())
811 .unwrap_or(remaining.len());
812
813 let key = &remaining[..key_end];
814 remaining = &remaining[key_end..];
815
816 if remaining.starts_with('=') {
817 remaining = &remaining[1..];
818 let value = if remaining.starts_with('"') {
819 remaining = &remaining[1..];
820 let end = remaining.find('"').unwrap_or(remaining.len());
821 let val = &remaining[..end];
822 remaining = &remaining[(end + 1).min(remaining.len())..];
823 val.to_string()
824 } else {
825 let end = remaining
826 .find(char::is_whitespace)
827 .unwrap_or(remaining.len());
828 let val = &remaining[..end];
829 remaining = &remaining[end..];
830 val.to_string()
831 };
832 attrs.insert(key.to_string(), value);
833 } else {
834 attrs.insert(key.to_string(), "true".to_string());
835 }
836 }
837 attrs
838}
839
840fn parse_fence_info(info: &str) -> (String, HashMap<String, String>) {
841 let mut lang = String::new();
842 let mut attrs = HashMap::new();
843 for (i, raw) in info.split(',').enumerate() {
844 let token = raw.trim();
845 if token.is_empty() { continue; }
846 if i == 0 && !token.contains('=') {
847 lang = token.to_string();
848 continue;
849 }
850 if let Some(eq) = token.find('=') {
851 let (k, v) = token.split_at(eq);
852 attrs.insert(k.trim().to_string(), v[1..].trim().to_string());
853 } else {
854 attrs.insert(token.to_string(), "true".to_string());
855 }
856 }
857 (lang, attrs)
858}
859
860fn extract_code_block(content: &str) -> (Option<String>, String) {
861 let lines: Vec<&str> = content.lines().collect();
862 let mut in_code_block = false;
863 let mut language = None;
864 let mut code_lines = Vec::new();
865
866 for line in lines {
867 if line.trim().starts_with("```") {
868 if in_code_block {
869 break;
870 } else {
871 in_code_block = true;
872 let lang = line.trim().trim_start_matches('`').trim();
873 if !lang.is_empty() {
874 language = Some(lang.to_string());
875 }
876 }
877 } else if in_code_block {
878 code_lines.push(line);
879 }
880 }
881
882 (language, code_lines.join("\n"))
883}
884
885fn extract_explanation(content: &str) -> Option<String> {
886 let mut in_code_block = false;
887 let mut found_code_block = false;
888 let mut explanation_lines = Vec::new();
889
890 for line in content.lines() {
891 if line.trim().starts_with("```") {
892 if in_code_block {
893 in_code_block = false;
894 found_code_block = true;
895 } else {
896 in_code_block = true;
897 }
898 } else if found_code_block && !in_code_block {
899 explanation_lines.push(line);
900 }
901 }
902
903 let explanation = explanation_lines.join("\n").trim().to_string();
904 let explanation = explanation
905 .strip_prefix("### Explanation")
906 .unwrap_or(&explanation)
907 .trim()
908 .to_string();
909
910 if explanation.is_empty() {
911 None
912 } else {
913 Some(explanation)
914 }
915}
916
917fn parse_markdown_list(content: &str) -> Vec<String> {
918 content
919 .lines()
920 .filter_map(|line| {
921 let trimmed = line.trim();
922 if trimmed.starts_with('-') || trimmed.starts_with('*') {
923 Some(trimmed[1..].trim().to_string())
924 } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('.') {
925 let dot_pos = trimmed.find('.')?;
926 Some(trimmed[dot_pos + 1..].trim().to_string())
927 } else {
928 None
929 }
930 })
931 .filter(|s| !s.is_empty())
932 .collect()
933}
934
935fn parse_time_string(time: &str) -> Option<u32> {
936 let parts: Vec<&str> = time.split_whitespace().collect();
937 if parts.is_empty() { return None; }
938 let number: u32 = parts[0].parse().ok()?;
939 if parts.len() > 1 {
940 let unit = parts[1].to_lowercase();
941 if unit.starts_with("hour") {
942 return Some(number * 60);
943 }
944 }
945 Some(number)
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951
952 #[test]
953 fn test_parse_simple_code_exercise() {
954 let markdown = r#"
955# Hello World
956
957::: exercise
958id: hello-world
959difficulty: beginner
960time: 10 minutes
961:::
962
963Write a greeting function.
964"#;
965 match parse_exercise(markdown).unwrap() {
966 ParsedExercise::Code(exercise) => {
967 assert_eq!(exercise.metadata.id, "hello-world");
968 assert_eq!(exercise.metadata.difficulty, Difficulty::Beginner);
969 assert_eq!(exercise.metadata.time_minutes, Some(10));
970 assert_eq!(exercise.title, Some("Hello World".to_string()));
971 }
972 _ => panic!("Expected Code exercise"),
973 }
974 }
975
976 #[test]
977 fn test_parse_usecase_exercise() {
978 let markdown = r#"
979# Security Analysis
980
981::: usecase
982id: sec-01
983domain: healthcare
984difficulty: intermediate
985:::
986
987::: scenario
988organization: HealthCorp
989The scenario text.
990:::
991
992::: prompt
993Analyze the security.
994:::
995"#;
996 match parse_exercise(markdown).unwrap() {
997 ParsedExercise::UseCase(exercise) => {
998 assert_eq!(exercise.metadata.id, "sec-01");
999 assert_eq!(exercise.metadata.domain, UseCaseDomain::Healthcare);
1000 assert_eq!(exercise.scenario.organization, Some("HealthCorp".to_string()));
1001 assert!(exercise.scenario.content.contains("The scenario text"));
1002 }
1003 _ => panic!("Expected UseCase exercise"),
1004 }
1005 }
1006}