mdbook_exercises/
parser.rs

1//! Parser for exercise markdown with directive blocks.
2//!
3//! This module handles parsing markdown files that contain exercise directives
4//! like `::: exercise`, `::: hint`, `::: solution`, etc.
5
6use crate::types::*;
7use pulldown_cmark::{Event, Parser, Tag, TagEnd};
8use std::collections::HashMap;
9use std::ops::Range;
10use thiserror::Error;
11
12/// Errors that can occur during parsing.
13#[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
41/// Result type for parsing operations.
42pub type ParseResult<T> = Result<T, ParseError>;
43
44/// A parsed directive with its type and attributes.
45#[derive(Debug)]
46struct Directive {
47    /// The directive name (e.g., "exercise", "hint", "solution")
48    name: String,
49
50    /// Inline attributes (e.g., level=1, file="src/main.rs")
51    attributes: HashMap<String, String>,
52
53    /// The line number where this directive started
54    line: usize,
55}
56
57/// Parse a markdown file containing exercise directives.
58pub fn parse_exercise(markdown: &str) -> ParseResult<ParsedExercise> {
59    // Detect exercise type based on the presence of specific directives
60    // This is a simple heuristic: scan for ::: exercise vs ::: usecase
61    // We ignore code blocks for this check to avoid false positives in examples
62
63    let excluded = find_excluded_ranges(markdown);
64    
65    // Check for usecase directive
66    if contains_directive(markdown, "usecase", &excluded) {
67        return parse_usecase_exercise(markdown, excluded).map(ParsedExercise::UseCase);
68    }
69    
70    // Check for exercise directive
71    if contains_directive(markdown, "exercise", &excluded) {
72        return parse_code_exercise(markdown, excluded).map(ParsedExercise::Code);
73    }
74
75    // Default to error if neither is found
76    Err(ParseError::UnknownExerciseType)
77}
78
79/// Check if the markdown contains a specific directive, ignoring excluded ranges.
80fn 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; // Approx +1 for newline
86        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
98/// Parse a code exercise (original format).
99fn 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
167/// Parse a UseCase exercise.
168fn 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
236/// Process a directive block for code exercises.
237fn 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            // Unknown directive - ignore
249        }
250    }
251    Ok(())
252}
253
254/// Process a directive block for UseCase exercises.
255fn 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            // Unknown directive - ignore
267        }
268    }
269    Ok(())
270}
271
272// --- Common Parsers ---
273
274fn 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
330// --- Code Exercise Specific Parsers ---
331
332fn 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
499// --- UseCase Exercise Specific Parsers ---
500
501fn 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    // Split content into YAML header lines and markdown content
554    // YAML lines look like "key: value" or "key:" followed by list items
555    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            // Check if this looks like a YAML key-value line
564            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                // Skip leading blank lines
571            } else {
572                // This line doesn't look like YAML, start content
573                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    // YAML header (aspects) + markdown body
601    let mut prompt = UseCasePrompt::default();
602
603    // Split content into YAML header lines and markdown content
604    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                // Skip leading blank lines
619            } 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    // Evaluation block is pure YAML
645    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    // Check for expected_score in header
686    // Content is markdown
687    
688    let mut answer = SampleAnswer {
689        content: String::new(),
690        expected_score: None,
691        reveal: SolutionReveal::OnDemand, // Default
692    };
693    
694    // Parse reveal attr
695    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    // Try to find expected_score in content (YAML-ish header)
704    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             // skip blank lines at top
717             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
735// --- Helper Functions ---
736
737fn 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}