Skip to main content

md_tmpl/frontmatter/
mod.rs

1//! YAML-style frontmatter parsing for `.tmpl.md` files.
2//!
3//! Extracts template metadata (name, description, typed variable declarations)
4//! from the `---`-delimited block at the start of a template source string.
5//!
6//! ## Frontmatter v2 format
7//!
8//! Uses `=` for name-type pairs, `<>` for type parameters, `:=` for defaults:
9//!
10//! ```text
11//! ---
12//! name: my_template
13//! params:
14//!   - name = str
15//!   - count = int := 42
16//!   - tasks = list(title = str, priority = int)
17//! ---
18//! ```
19
20mod imports;
21mod params;
22mod type_aliases;
23mod validation;
24
25use alloc::{
26    string::{String, ToString},
27    vec::Vec,
28};
29#[cfg(feature = "std")]
30use std::path::PathBuf;
31
32pub use imports::*;
33pub use params::parse_type_annotation;
34pub(crate) use params::*;
35pub(crate) use type_aliases::*;
36pub(crate) use validation::*;
37
38use crate::{
39    compat::HashMap,
40    consts::{
41        FM_ALLOW_UNUSED_PREFIX, FM_CONSTS_PREFIX, FM_DELIMITER, FM_DELIMITER_NEWLINE,
42        FM_DESC_PREFIX, FM_IMPORTS_PREFIX, FM_NAME_PREFIX, FM_PARAMS_PREFIX, FM_TYPES_PREFIX,
43    },
44    error::TemplateError,
45    frontmatter::params::parse_declarations,
46    types::{VarDecl, VarType},
47};
48
49/// A template import declaration: `[stem](path.tmpl.md)`.
50#[derive(Debug, Clone)]
51pub struct Import {
52    /// Short alias used as namespace prefix, e.g. `other`.
53    pub stem: String,
54    /// Relative path to the imported template file.
55    #[cfg(feature = "std")]
56    pub path: PathBuf,
57    /// Relative path as a string (always available).
58    #[cfg(not(feature = "std"))]
59    pub path: alloc::string::String,
60}
61
62/// Resolved namespace from an imported template.
63#[derive(Debug, Clone, Default)]
64pub struct ImportedNamespace {
65    /// Type aliases exported by the imported template.
66    pub type_aliases: HashMap<String, VarType>,
67    /// Parameter types (for cross-template type references).
68    pub param_types: HashMap<String, VarType>,
69    /// Constants exported by the imported template.
70    pub consts: HashMap<String, crate::value::Value>,
71}
72
73/// Parsed YAML frontmatter from a `.tmpl.md` file.
74#[derive(Debug, Clone, Default)]
75pub struct Frontmatter {
76    /// Template name (matches SKILL.md `name:` convention).
77    pub name: Option<String>,
78    /// Description of the template's purpose.
79    pub description: Option<String>,
80    /// List of expected variable declarations (name + type + optional default).
81    pub declarations: Vec<VarDecl>,
82    /// Convenience: parameter names only (derived from `declarations`).
83    pub params: Vec<String>,
84    /// Whether the params: block was present in frontmatter.
85    pub has_params: bool,
86    /// Allow declared parameters that are never referenced in the body.
87    ///
88    /// Set via `allow_unused: true` in frontmatter. Useful for
89    /// dynamically-loaded templates where params may be conditionally used.
90    pub allow_unused: bool,
91    /// Type aliases defined via `types:` in frontmatter.
92    ///
93    /// Maps alias names (e.g. `Priority`) to their resolved [`VarType`].
94    pub type_aliases: HashMap<String, VarType>,
95    /// Import declarations defined via `imports:` in frontmatter.
96    pub imports: Vec<Import>,
97    /// Constants defined via `consts:` in frontmatter.
98    pub consts: Vec<VarDecl>,
99    /// Resolved constants from imports, keyed by `stem.NAME`.
100    pub imported_consts: HashMap<String, crate::value::Value>,
101    /// Keys in `imported_consts` that are enum type namespace dicts
102    /// (injected from imported enum type aliases). Used by the bare-enum-access
103    /// check to distinguish enum namespaces from struct constants.
104    pub imported_enum_type_keys: Vec<String>,
105}
106
107/// Strip YAML frontmatter delimited by `---` and return only the body text.
108///
109/// # Errors
110///
111/// Returns [`TemplateError::Syntax`] if the frontmatter block is missing or invalid.
112pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
113    parse_frontmatter(source).map(|(_, body)| body)
114}
115
116/// Parse YAML frontmatter delimited by `---` lines.
117///
118/// Returns the parsed [`Frontmatter`] and a string slice pointing to the
119/// template body after the closing `---`.
120///
121/// # Errors
122///
123/// Returns [`TemplateError::Syntax`] if the frontmatter block is
124/// missing, unclosed, or contains invalid declarations.
125pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
126    let trimmed = source.trim_start();
127    if !trimmed.starts_with(FM_DELIMITER) {
128        return Err(TemplateError::syntax(
129            crate::consts::ERR_MISSING_FM.to_string(),
130        ));
131    }
132
133    // Find the closing `---`.
134    let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
135    let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
136        return Err(TemplateError::syntax(
137            crate::consts::ERR_UNCLOSED_FM.to_string(),
138        ));
139    };
140
141    let yaml_block = &after_first[..end];
142    // Skip past "\n---" (4 chars), then skip the trailing newline if present.
143    let after_close = end + FM_DELIMITER_NEWLINE.len();
144    let body_start = if after_first[after_close..].starts_with('\n') {
145        after_close + 1
146    } else if after_first[after_close..].starts_with("\r\n") {
147        after_close + 2
148    } else {
149        after_close
150    };
151    let body = &after_first[body_start..];
152
153    let mut fm = Frontmatter::default();
154
155    // Validate Frontmatter List Termination Rule:
156    // A blank line is strictly required after a block list before starting a new top-level
157    // section keyword, so raw markdown renders correctly.
158    let mut in_block_list = false;
159    let mut had_blank_line = true;
160    for line in yaml_block.lines() {
161        let trimmed = line.trim();
162        if trimmed.is_empty() {
163            had_blank_line = true;
164            continue;
165        }
166        let starts_with_section = line.starts_with(FM_NAME_PREFIX)
167            || line.starts_with(FM_DESC_PREFIX)
168            || line.starts_with(FM_TYPES_PREFIX)
169            || line.starts_with(FM_IMPORTS_PREFIX)
170            || line.starts_with(FM_PARAMS_PREFIX)
171            || line.starts_with(FM_CONSTS_PREFIX)
172            || line.starts_with(FM_ALLOW_UNUSED_PREFIX);
173
174        if starts_with_section {
175            if in_block_list && !had_blank_line {
176                return Err(TemplateError::syntax(format!(
177                    "A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
178                )));
179            }
180            in_block_list = false;
181        } else if trimmed.starts_with('-') {
182            in_block_list = true;
183        }
184        had_blank_line = false;
185    }
186
187    // Collect all raw lines, then join continuation lines (lines starting with
188    // whitespace) back onto their parent so that multiline `params:` blocks
189    // are handled correctly.
190    let logical_lines = join_continuation_lines(yaml_block);
191
192    // --- Pass 1: Collect types:, imports:, and simple keys ---
193    let mut params_raw: Option<String> = None;
194    let mut consts_raw: Option<String> = None;
195
196    for line in &logical_lines {
197        let line = line.trim();
198        if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
199            fm.name = Some(rest.trim().to_string());
200        } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
201            fm.description = Some(rest.trim().to_string());
202        } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
203            fm.type_aliases = parse_types_value(rest)?;
204        } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
205            fm.imports = parse_imports_value(rest)?;
206        } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
207            params_raw = Some(rest.to_string());
208        } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
209            consts_raw = Some(rest.to_string());
210        } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
211            fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
212        }
213    }
214
215    // --- Pass 2: Parse consts first, then params with const values available ---
216    let resolved_imports = HashMap::new();
217    let empty_consts = HashMap::new();
218    if let Some(raw) = consts_raw {
219        fm.consts = parse_declarations(
220            &raw,
221            &fm.type_aliases,
222            &resolved_imports,
223            true,
224            &empty_consts,
225        )?;
226    }
227
228    // Build available_consts from parsed const declarations so params can
229    // reference const names as default values.
230    let available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
231
232    if let Some(raw) = params_raw {
233        let decls = parse_declarations(
234            &raw,
235            &fm.type_aliases,
236            &resolved_imports,
237            false,
238            &available_consts,
239        )?;
240        fm.params = decls.iter().map(|d| d.name.clone()).collect();
241        fm.declarations = decls;
242        fm.has_params = true;
243    }
244
245    validate_collision_rules(&fm)?;
246    add_implicit_param_types(&mut fm);
247
248    Ok((fm, body))
249}
250
251/// Parse YAML frontmatter with cross-template import resolution.
252///
253/// Like [`parse_frontmatter`], but additionally resolves `imports:` entries
254/// by reading referenced template files from disk relative to `base_dir`.
255/// This allows params to reference imported types (e.g. `types.Severity`).
256///
257/// # Errors
258///
259/// Returns [`TemplateError::Syntax`] if the frontmatter block is invalid,
260/// an imported file cannot be read, or imported types cannot be resolved.
261#[cfg(feature = "std")]
262pub fn parse_frontmatter_with_base_dir<'a>(
263    source: &'a str,
264    base_dir: &std::path::Path,
265) -> Result<(Frontmatter, &'a str), TemplateError> {
266    let trimmed = source.trim_start();
267    if !trimmed.starts_with(FM_DELIMITER) {
268        return Err(TemplateError::syntax(
269            crate::consts::ERR_MISSING_FM.to_string(),
270        ));
271    }
272
273    // Find the closing `---`.
274    let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
275    let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
276        return Err(TemplateError::syntax(
277            crate::consts::ERR_UNCLOSED_FM.to_string(),
278        ));
279    };
280
281    let yaml_block = &after_first[..end];
282    let after_close = end + FM_DELIMITER_NEWLINE.len();
283    let body_start = if after_first[after_close..].starts_with('\n') {
284        after_close + 1
285    } else if after_first[after_close..].starts_with("\r\n") {
286        after_close + 2
287    } else {
288        after_close
289    };
290    let body = &after_first[body_start..];
291
292    let mut fm = Frontmatter::default();
293    let logical_lines = join_continuation_lines(yaml_block);
294
295    // --- Pass 1: Collect types:, imports:, and simple keys ---
296    let mut params_raw: Option<String> = None;
297    let mut consts_raw: Option<String> = None;
298
299    for line in &logical_lines {
300        let line = line.trim();
301        if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
302            fm.name = Some(rest.trim().to_string());
303        } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
304            fm.description = Some(rest.trim().to_string());
305        } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
306            fm.type_aliases = parse_types_value(rest)?;
307        } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
308            fm.imports = parse_imports_value(rest)?;
309        } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
310            params_raw = Some(rest.to_string());
311        } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
312            consts_raw = Some(rest.to_string());
313        } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
314            fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
315        }
316    }
317
318    let resolved_imports = if fm.imports.is_empty() {
319        HashMap::new()
320    } else {
321        let mut visited = std::collections::HashSet::new();
322        resolve_imports(&fm.imports, base_dir, &mut visited)?
323    };
324
325    inject_imported_consts(&mut fm, &resolved_imports);
326
327    let empty_consts = HashMap::new();
328    if let Some(raw) = consts_raw {
329        fm.consts = parse_declarations(
330            &raw,
331            &fm.type_aliases,
332            &resolved_imports,
333            true,
334            &empty_consts,
335        )?;
336    }
337
338    // Build available_consts from parsed const declarations and imported consts
339    // so params can reference const names as default values.
340    let available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
341
342    if let Some(raw) = params_raw {
343        let decls = parse_declarations(
344            &raw,
345            &fm.type_aliases,
346            &resolved_imports,
347            false,
348            &available_consts,
349        )?;
350        fm.params = decls.iter().map(|d| d.name.clone()).collect();
351        fm.declarations = decls;
352        fm.has_params = true;
353    }
354
355    validate_collision_rules(&fm)?;
356    add_implicit_param_types(&mut fm);
357
358    Ok((fm, body))
359}
360
361/// Parse YAML frontmatter with access to a parent template's type aliases.
362///
363/// Used for inline template definitions (`{% tmpl %}` blocks) that can
364/// reference type aliases from the enclosing template. The inline's own
365/// `types:` block shadows the parent's (resolution order: own → parent).
366///
367/// This is the same as [`parse_frontmatter`] except params can reference
368/// parent type aliases for type resolution.
369pub fn parse_frontmatter_with_parent_scope<'a>(
370    source: &'a str,
371    parent_type_aliases: &HashMap<String, VarType>,
372) -> Result<(Frontmatter, &'a str), TemplateError> {
373    let trimmed = source.trim_start();
374    if !trimmed.starts_with(FM_DELIMITER) {
375        // Inline templates may omit frontmatter entirely — treat the whole
376        // source as body with no params. The parent's type aliases are still
377        // available for any future extensions.
378        return Ok((Frontmatter::default(), source));
379    }
380
381    let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
382    let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
383        return Err(TemplateError::syntax(
384            crate::consts::ERR_UNCLOSED_FM.to_string(),
385        ));
386    };
387
388    let yaml_block = &after_first[..end];
389    let after_close = end + FM_DELIMITER_NEWLINE.len();
390    let body_start = if after_first[after_close..].starts_with('\n') {
391        after_close + 1
392    } else if after_first[after_close..].starts_with("\r\n") {
393        after_close + 2
394    } else {
395        after_close
396    };
397    let body = &after_first[body_start..];
398
399    let mut fm = Frontmatter::default();
400    let logical_lines = join_continuation_lines(yaml_block);
401
402    // --- Pass 1: Collect own types:, imports:, and simple keys ---
403    let mut params_raw: Option<String> = None;
404    let mut consts_raw: Option<String> = None;
405
406    for line in &logical_lines {
407        let line = line.trim();
408        if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
409            fm.name = Some(rest.trim().to_string());
410        } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
411            fm.description = Some(rest.trim().to_string());
412        } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
413            fm.type_aliases = parse_types_value(rest)?;
414        } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
415            fm.imports = parse_imports_value(rest)?;
416        } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
417            params_raw = Some(rest.to_string());
418        } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
419            consts_raw = Some(rest.to_string());
420        } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
421            fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
422        }
423    }
424
425    // --- Pass 2: Parse consts first, then params with const values available ---
426    // Build merged alias map: parent first, then own (own shadows parent).
427    let mut merged_aliases = parent_type_aliases.clone();
428    for (k, v) in &fm.type_aliases {
429        merged_aliases.insert(k.clone(), v.clone());
430    }
431    let resolved_imports = HashMap::new();
432    let empty_consts = HashMap::new();
433    if let Some(raw) = consts_raw {
434        fm.consts = parse_declarations(
435            &raw,
436            &merged_aliases,
437            &resolved_imports,
438            true,
439            &empty_consts,
440        )?;
441    }
442
443    // Build available_consts from parsed const declarations so params can
444    // reference const names as default values.
445    let available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
446
447    if let Some(raw) = params_raw {
448        let decls = parse_declarations(
449            &raw,
450            &merged_aliases,
451            &resolved_imports,
452            false,
453            &available_consts,
454        )?;
455        fm.params = decls.iter().map(|d| d.name.clone()).collect();
456        fm.declarations = decls;
457        fm.has_params = true;
458    }
459
460    validate_collision_rules(&fm)?;
461    add_implicit_param_types(&mut fm);
462
463    Ok((fm, body))
464}
465
466/// Inject imported constants and enum type namespace dicts into `fm`.
467///
468/// For each import namespace, copies over user-defined constants and
469/// synthesizes enum type namespace dicts so that `{{ lib.EnumType.Variant }}`
470/// expressions work.
471#[cfg(feature = "std")]
472fn inject_imported_consts(
473    fm: &mut Frontmatter,
474    resolved_imports: &HashMap<String, ImportedNamespace>,
475) {
476    for (stem, ns) in resolved_imports {
477        for (name, val) in &ns.consts {
478            fm.imported_consts
479                .insert(format!("{stem}.{name}"), val.clone());
480        }
481        // Inject enum type aliases from the imported namespace as constants,
482        // enabling `{{ lib.EnumType.Variant }}` expressions.
483        for (type_name, var_type) in &ns.type_aliases {
484            let VarType::Enum(variants) = var_type else {
485                continue;
486            };
487            let key = format!("{stem}.{type_name}");
488            // Don't overwrite a user-defined constant with the same name.
489            if fm.imported_consts.contains_key(&key) {
490                continue;
491            }
492            let mut variant_map = HashMap::new();
493            for variant in variants {
494                if variant.fields.is_empty() {
495                    variant_map.insert(
496                        variant.name.clone(),
497                        crate::value::Value::Str(variant.name.clone()),
498                    );
499                } else {
500                    let mut partial = HashMap::new();
501                    partial.insert(
502                        crate::consts::ENUM_TAG_KEY.into(),
503                        crate::value::Value::Str(variant.name.clone()),
504                    );
505                    variant_map.insert(
506                        variant.name.clone(),
507                        crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
508                    );
509                }
510            }
511            fm.imported_consts.insert(
512                key.clone(),
513                crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
514            );
515            fm.imported_enum_type_keys.push(key);
516        }
517    }
518}
519
520/// Build a lookup map of available constants for use as param default values.
521///
522/// Merges local constants (from `consts:` declarations) with imported constants
523/// (from `imports:`) into a single flat map. Local consts are keyed by their
524/// bare name (e.g. `MAX`), imported consts are already keyed by `stem.NAME`
525/// (e.g. `lib.LIMIT`).
526fn build_available_consts(
527    consts: &[crate::types::VarDecl],
528    imported_consts: &HashMap<String, crate::value::Value>,
529) -> HashMap<String, crate::value::Value> {
530    let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
531    // Add local consts.
532    for d in consts {
533        if let Some(ref v) = d.default_value {
534            available.insert(d.name.clone(), v.clone());
535        }
536    }
537    // Add imported consts (stem.NAME keys).
538    for (k, v) in imported_consts {
539        available.insert(k.clone(), v.clone());
540    }
541    available
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::value::Value;
548
549    /// Wrapper for `parse_type_annotation` without aliases — for baseline tests.
550    fn parse_type_annotation(s: &str) -> Result<VarType, String> {
551        let empty_aliases = HashMap::new();
552        let empty_imports = HashMap::new();
553        super::parse_type_annotation(s, &empty_aliases, &empty_imports)
554    }
555
556    /// Wrapper for `parse_declarations` without aliases — for baseline tests.
557    fn parse_params_value(rest: &str) -> Result<Vec<VarDecl>, TemplateError> {
558        let empty_aliases = HashMap::new();
559        let empty_imports = HashMap::new();
560        let empty_consts = HashMap::new();
561        super::parse_declarations(rest, &empty_aliases, &empty_imports, false, &empty_consts)
562    }
563
564    #[test]
565    fn parse_empty_source() {
566        let err = parse_frontmatter("").unwrap_err();
567        assert!(
568            err.to_string()
569                .contains("missing mandatory YAML frontmatter block")
570        );
571    }
572
573    #[test]
574    fn parse_no_frontmatter() {
575        let source = "Hello {{ name }}!";
576        let err = parse_frontmatter(source).unwrap_err();
577        assert!(
578            err.to_string()
579                .contains("missing mandatory YAML frontmatter block")
580        );
581    }
582
583    #[test]
584    fn parse_basic_frontmatter() {
585        let source = r"---
586name: greeting
587description: A greeting template
588params: [name = str, count = int]
589---
590Hello {{ name }}!";
591        let (fm, body) = parse_frontmatter(source).unwrap();
592        assert_eq!(fm.name, Some("greeting".to_string()));
593        assert_eq!(fm.description, Some("A greeting template".to_string()));
594        assert_eq!(fm.params, vec!["name", "count"]);
595        assert_eq!(fm.declarations.len(), 2);
596        assert_eq!(fm.declarations[0].name, "name");
597        assert_eq!(fm.declarations[0].var_type, VarType::Str);
598        assert_eq!(fm.declarations[1].name, "count");
599        assert_eq!(fm.declarations[1].var_type, VarType::Int);
600        assert_eq!(body, "Hello {{ name }}!");
601    }
602
603    #[test]
604    fn reject_untyped_params() {
605        let source = r"---
606params: [a, b, c]
607---
608body";
609        let err = parse_frontmatter(source).unwrap_err();
610        assert!(err.to_string().contains("missing a type annotation"));
611    }
612
613    #[test]
614    fn parse_multiline_block_format() {
615        let source = r"---
616name: test
617params:
618  - a = str
619  - b = int
620---
621{{ a }} {{ b }}";
622        let (fm, body) = parse_frontmatter(source).unwrap();
623        assert_eq!(fm.params, vec!["a", "b"]);
624        assert_eq!(fm.declarations[0].var_type, VarType::Str);
625        assert_eq!(fm.declarations[1].var_type, VarType::Int);
626        assert_eq!(body, "{{ a }} {{ b }}");
627    }
628
629    #[test]
630    fn parse_list_with_fields() {
631        let source = r"---
632params: [items = list(title = str, score = float)]
633---
634body";
635        let (fm, _) = parse_frontmatter(source).unwrap();
636        assert_eq!(fm.declarations.len(), 1);
637        assert_eq!(fm.declarations[0].name, "items");
638        match &fm.declarations[0].var_type {
639            VarType::List(fields) => {
640                assert_eq!(fields.len(), 2);
641                assert_eq!(fields[0].name, "title");
642                assert_eq!(fields[0].var_type, VarType::Str);
643                assert_eq!(fields[1].name, "score");
644                assert_eq!(fields[1].var_type, VarType::Float);
645            }
646            other => panic!("Expected List, got {other:?}"),
647        }
648    }
649
650    #[test]
651    fn parse_struct_type() {
652        let source = r"---
653params: [config = struct(key = str, enabled = bool)]
654---
655body";
656        let (fm, _) = parse_frontmatter(source).unwrap();
657        assert_eq!(fm.declarations.len(), 1);
658        match &fm.declarations[0].var_type {
659            VarType::Struct(fields) => {
660                assert_eq!(fields.len(), 2);
661                assert_eq!(fields[0].name, "key");
662                assert_eq!(fields[0].var_type, VarType::Str);
663                assert_eq!(fields[1].name, "enabled");
664                assert_eq!(fields[1].var_type, VarType::Bool);
665            }
666            other => panic!("Expected Struct, got {other:?}"),
667        }
668    }
669
670    #[test]
671    fn reject_bare_list_type() {
672        let source = r"---
673params: [items = list]
674---
675body";
676        let err = parse_frontmatter(source).unwrap_err();
677        assert!(err.to_string().contains("unknown type"));
678    }
679
680    #[test]
681    fn parse_float_type() {
682        let source = r"---
683params: [score = float]
684---
685body";
686        let (fm, _) = parse_frontmatter(source).unwrap();
687        assert_eq!(fm.declarations[0].var_type, VarType::Float);
688    }
689
690    #[test]
691    fn parse_bool_type() {
692        let source = r"---
693params: [active = bool]
694---
695body";
696        let (fm, _) = parse_frontmatter(source).unwrap();
697        assert_eq!(fm.declarations[0].var_type, VarType::Bool);
698    }
699
700    #[test]
701    fn reject_unknown_type() {
702        let source = r"---
703params: [x = unknown_type]
704---
705body";
706        let err = parse_frontmatter(source).unwrap_err();
707        assert!(err.to_string().contains("unknown type 'unknown_type'"));
708    }
709
710    #[test]
711    fn reject_mixed_typed_and_untyped() {
712        let source = r"---
713params: [name = str, label, count = int]
714---
715body";
716        let err = parse_frontmatter(source).unwrap_err();
717        assert!(err.to_string().contains("missing a type annotation"));
718    }
719
720    #[test]
721    fn parse_empty_params_list() {
722        let source = r"---
723params: []
724---
725body";
726        let (fm, _) = parse_frontmatter(source).unwrap();
727        assert!(fm.declarations.is_empty());
728        assert!(fm.params.is_empty());
729    }
730
731    #[test]
732    fn reject_missing_blank_line_after_block_list() {
733        let source = r"---
734consts:
735  - FOO = str := 'bar'
736params:
737  - x = int
738---
739body";
740        let err = parse_frontmatter(source).unwrap_err();
741        assert!(
742            err.to_string()
743                .contains("A blank line is required after a block list"),
744            "got: {err}"
745        );
746    }
747
748    #[test]
749    fn types_only_template_no_params_block() {
750        let source = r"---
751name: types
752types:
753  - Priority = enum(High, Medium, Low)
754---
755{# no body #}";
756        let (fm, body) = parse_frontmatter(source).unwrap();
757        assert_eq!(fm.name, Some("types".to_string()));
758        assert!(fm.declarations.is_empty());
759        assert!(fm.params.is_empty());
760        assert!(!fm.has_params);
761        assert!(fm.type_aliases.contains_key("Priority"));
762        assert_eq!(body, "{# no body #}");
763    }
764
765    #[test]
766    fn frontmatter_not_at_start() {
767        let source = "some text\n---\nname: test\n---\nbody";
768        let err = parse_frontmatter(source).unwrap_err();
769        assert!(
770            err.to_string()
771                .contains("missing mandatory YAML frontmatter block")
772        );
773    }
774
775    #[test]
776    fn frontmatter_without_closing_delimiter() {
777        let source = r"---
778name: test
779no closing delimiter";
780        let err = parse_frontmatter(source).unwrap_err();
781        assert!(err.to_string().contains("unclosed YAML frontmatter block"));
782    }
783
784    #[test]
785    fn join_continuation_lines_basic() {
786        let block = "key1: val1\nkey2:\n  continued\n  more";
787        let lines = join_continuation_lines(block);
788        assert_eq!(lines.len(), 2);
789        assert_eq!(lines[0], "key1: val1");
790        assert!(lines[1].contains("continued"));
791        assert!(lines[1].contains("more"));
792    }
793
794    #[test]
795    fn parse_type_annotation_all_simple_types() {
796        assert_eq!(parse_type_annotation("str").unwrap(), VarType::Str);
797        assert_eq!(parse_type_annotation("bool").unwrap(), VarType::Bool);
798        assert_eq!(parse_type_annotation("int").unwrap(), VarType::Int);
799        assert_eq!(parse_type_annotation("float").unwrap(), VarType::Float);
800        parse_type_annotation("garbage").expect_err("unknown type 'garbage' should be rejected");
801        parse_type_annotation("list").expect_err("bare 'list' without <fields> should be rejected");
802        parse_type_annotation("struct")
803            .expect_err("bare 'struct' without <fields> should be rejected");
804    }
805
806    #[test]
807    fn parse_type_annotation_with_whitespace() {
808        assert_eq!(parse_type_annotation("  str  ").unwrap(), VarType::Str);
809        assert_eq!(parse_type_annotation("\tint\t").unwrap(), VarType::Int);
810    }
811
812    #[test]
813    fn parse_params_complex() {
814        let rest = "[name = str, items = list(label = str, count = int), active = bool]";
815        let decls = parse_params_value(rest).unwrap();
816        assert_eq!(decls.len(), 3);
817        assert_eq!(decls[0].name, "name");
818        assert_eq!(decls[0].var_type, VarType::Str);
819        assert_eq!(decls[2].name, "active");
820        assert_eq!(decls[2].var_type, VarType::Bool);
821        match &decls[1].var_type {
822            VarType::List(fields) => {
823                assert_eq!(fields.len(), 2);
824                assert_eq!(fields[0].name, "label");
825                assert_eq!(fields[1].name, "count");
826            }
827            other => panic!("Expected List, got {other:?}"),
828        }
829    }
830
831    #[test]
832    fn parse_enum_with_associated_data() {
833        let rest = "[outcome = enum(Confirmed(evidence = list(text = str)), Inconclusive)]";
834        let decls = parse_params_value(rest).unwrap();
835        assert_eq!(decls.len(), 1);
836        assert_eq!(decls[0].name, "outcome");
837        match &decls[0].var_type {
838            VarType::Enum(variants) => {
839                assert_eq!(variants.len(), 2);
840                assert_eq!(variants[0].name, "Confirmed");
841                assert_eq!(variants[0].fields.len(), 1);
842                assert_eq!(variants[0].fields[0].name, "evidence");
843                assert_eq!(variants[1].name, "Inconclusive");
844                assert!(variants[1].fields.is_empty());
845            }
846            other => panic!("Expected Enum, got {other:?}"),
847        }
848    }
849
850    // -- Default value tests --
851
852    #[test]
853    fn parse_string_default() {
854        let source = r#"---
855params: [name = str := "hello world"]
856---
857body"#;
858        let (fm, _) = parse_frontmatter(source).unwrap();
859        assert_eq!(fm.declarations[0].name, "name");
860        assert_eq!(fm.declarations[0].var_type, VarType::Str);
861        assert_eq!(
862            fm.declarations[0].default_value,
863            Some(Value::Str("hello world".to_string()))
864        );
865    }
866
867    #[test]
868    fn parse_int_default() {
869        let source = r"---
870params: [count = int := 42]
871---
872body";
873        let (fm, _) = parse_frontmatter(source).unwrap();
874        assert_eq!(fm.declarations[0].var_type, VarType::Int);
875        assert_eq!(fm.declarations[0].default_value, Some(Value::Int(42)));
876    }
877
878    #[test]
879    fn parse_bool_default() {
880        let source = r"---
881params: [active = bool := true]
882---
883body";
884        let (fm, _) = parse_frontmatter(source).unwrap();
885        assert_eq!(fm.declarations[0].var_type, VarType::Bool);
886        assert_eq!(fm.declarations[0].default_value, Some(Value::Bool(true)));
887    }
888
889    #[test]
890    fn parse_float_default() {
891        let source = r"---
892params: [score = float := 3.15]
893---
894body";
895        let (fm, _) = parse_frontmatter(source).unwrap();
896        assert_eq!(fm.declarations[0].var_type, VarType::Float);
897        assert_eq!(fm.declarations[0].default_value, Some(Value::Float(3.15)));
898    }
899
900    #[test]
901    fn parse_mixed_defaults_and_required() {
902        let source = r"---
903params: [name = str, count = int := 10]
904---
905body";
906        let (fm, _) = parse_frontmatter(source).unwrap();
907        assert_eq!(fm.declarations[0].default_value, None);
908        assert_eq!(fm.declarations[1].default_value, Some(Value::Int(10)));
909    }
910
911    #[test]
912    fn default_does_not_confuse_with_inner_colons() {
913        // The `:=` inside `<>` should not be treated as a default separator.
914        // This is handled by find_assign_default_at_depth_zero.
915        let source = r"---
916params: [tasks = list(title = str)]
917---
918body";
919        let (fm, _) = parse_frontmatter(source).unwrap();
920        assert_eq!(fm.declarations[0].default_value, None);
921        match &fm.declarations[0].var_type {
922            VarType::List(fields) => {
923                assert_eq!(fields[0].name, "title");
924                assert_eq!(fields[0].var_type, VarType::Str);
925            }
926            other => panic!("Expected List, got {other:?}"),
927        }
928    }
929
930    #[test]
931    fn parse_default_value_types() {
932        assert_eq!(
933            parse_default_value("\"hello\""),
934            Some(Value::Str("hello".to_string()))
935        );
936        assert_eq!(
937            parse_default_value("'world'"),
938            Some(Value::Str("world".to_string()))
939        );
940        assert_eq!(parse_default_value("42"), Some(Value::Int(42)));
941        assert_eq!(parse_default_value("-1"), Some(Value::Int(-1)));
942        assert_eq!(parse_default_value("3.15"), Some(Value::Float(3.15)));
943        assert_eq!(parse_default_value("true"), Some(Value::Bool(true)));
944        assert_eq!(parse_default_value("false"), Some(Value::Bool(false)));
945        assert_eq!(parse_default_value(""), None);
946    }
947
948    #[test]
949    fn parse_block_format_with_defaults() {
950        let source = r#"---
951params:
952  - name = str
953  - count = int := 5
954  - label = str := "default"
955---
956body"#;
957        let (fm, _) = parse_frontmatter(source).unwrap();
958        assert_eq!(fm.declarations.len(), 3);
959        assert_eq!(fm.declarations[0].default_value, None);
960        assert_eq!(fm.declarations[1].default_value, Some(Value::Int(5)));
961        assert_eq!(
962            fm.declarations[2].default_value,
963            Some(Value::Str("default".to_string()))
964        );
965    }
966
967    #[test]
968    fn parse_nested_types() {
969        let source = r"---
970params: [data = list(item = struct(name = str, tags = list(label = str)))]
971---
972body";
973        let (fm, _) = parse_frontmatter(source).unwrap();
974        match &fm.declarations[0].var_type {
975            VarType::List(fields) => {
976                assert_eq!(fields[0].name, "item");
977                match &fields[0].var_type {
978                    VarType::Struct(struct_fields) => {
979                        assert_eq!(struct_fields[0].name, "name");
980                        assert_eq!(struct_fields[0].var_type, VarType::Str);
981                        match &struct_fields[1].var_type {
982                            VarType::List(inner) => {
983                                assert_eq!(inner[0].name, "label");
984                                assert_eq!(inner[0].var_type, VarType::Str);
985                            }
986                            other => panic!("Expected inner List, got {other:?}"),
987                        }
988                    }
989                    other => panic!("Expected Struct, got {other:?}"),
990                }
991            }
992            other => panic!("Expected List, got {other:?}"),
993        }
994    }
995
996    #[test]
997    fn default_value_accessor() {
998        let decl = VarDecl {
999            name: "test".to_string(),
1000            var_type: VarType::Str,
1001            default_value: Some(Value::Str("hello".to_string())),
1002        };
1003        assert_eq!(decl.default_value(), Some(&Value::Str("hello".to_string())));
1004
1005        let no_default = VarDecl {
1006            name: "test".to_string(),
1007            var_type: VarType::Int,
1008            default_value: None,
1009        };
1010        assert_eq!(no_default.default_value(), None);
1011    }
1012
1013    // -- Strict default type validation --
1014
1015    #[test]
1016    fn reject_int_default_for_str_type() {
1017        let source = r"---
1018params: [name = str := 42]
1019---
1020body";
1021        let err = parse_frontmatter(source).unwrap_err();
1022        assert!(
1023            err.to_string().contains("value has type"),
1024            "expected type mismatch error, got: {err}"
1025        );
1026    }
1027
1028    #[test]
1029    fn reject_str_default_for_int_type() {
1030        let source = r#"---
1031params: [count = int := "hello"]
1032---
1033body"#;
1034        let err = parse_frontmatter(source).unwrap_err();
1035        assert!(
1036            err.to_string().contains("value has type"),
1037            "expected type mismatch error, got: {err}"
1038        );
1039    }
1040
1041    #[test]
1042    fn reject_bool_default_for_float_type() {
1043        let source = r"---
1044params: [score = float := true]
1045---
1046body";
1047        let err = parse_frontmatter(source).unwrap_err();
1048        assert!(
1049            err.to_string().contains("value has type"),
1050            "expected type mismatch error, got: {err}"
1051        );
1052    }
1053
1054    #[test]
1055    fn reject_float_default_for_bool_type() {
1056        let source = r"---
1057params: [active = bool := 3.15]
1058---
1059body";
1060        let err = parse_frontmatter(source).unwrap_err();
1061        assert!(
1062            err.to_string().contains("value has type"),
1063            "expected type mismatch error, got: {err}"
1064        );
1065    }
1066
1067    #[test]
1068    fn accept_matching_int_default() {
1069        let source = r"---
1070params: [count = int := 0]
1071---
1072{{ count }}";
1073        let (fm, _) = parse_frontmatter(source).unwrap();
1074        assert_eq!(fm.declarations[0].default_value, Some(Value::Int(0)));
1075    }
1076
1077    #[test]
1078    fn accept_matching_str_default() {
1079        let source = r#"---
1080params: [name = str := "hi"]
1081---
1082{{ name }}"#;
1083        let (fm, _) = parse_frontmatter(source).unwrap();
1084        assert_eq!(
1085            fm.declarations[0].default_value,
1086            Some(Value::Str("hi".to_string()))
1087        );
1088    }
1089
1090    #[test]
1091    fn accept_matching_bool_default() {
1092        let source = r"---
1093params: [active = bool := false]
1094---
1095{{ active }}";
1096        let (fm, _) = parse_frontmatter(source).unwrap();
1097        assert_eq!(fm.declarations[0].default_value, Some(Value::Bool(false)));
1098    }
1099
1100    #[test]
1101    fn accept_matching_float_default() {
1102        let source = r"---
1103params: [score = float := -1.5]
1104---
1105{{ score }}";
1106        let (fm, _) = parse_frontmatter(source).unwrap();
1107        assert_eq!(fm.declarations[0].default_value, Some(Value::Float(-1.5)));
1108    }
1109
1110    #[test]
1111    fn reject_negative_int_for_str() {
1112        let source = r"---
1113params: [label = str := -99]
1114---
1115body";
1116        let err = parse_frontmatter(source).unwrap_err();
1117        assert!(err.to_string().contains("value has type"));
1118    }
1119
1120    // -- Type library (allow_unused) tests --
1121
1122    #[test]
1123    fn allow_unused_suppresses_unused_type_alias() {
1124        let source = "\
1125---
1126
1127types:
1128  - Severity = enum(Low, Medium, High)
1129
1130params:
1131  - x = str
1132
1133allow_unused: true
1134---
1135type library";
1136        let (fm, _) = parse_frontmatter(source).unwrap();
1137        assert!(fm.allow_unused);
1138        assert!(fm.type_aliases.contains_key("Severity"));
1139    }
1140
1141    #[test]
1142    fn reject_unused_type_alias_without_allow_unused() {
1143        // Enum types are exempt from R4 (always auto-injected as constants).
1144        // Use a struct type alias to test the unused check.
1145        let source = "\
1146---
1147
1148types:
1149  - Config = struct(host = str, port = int)
1150
1151params:
1152  - x = str
1153---
1154{{ x }}";
1155        let err = parse_frontmatter(source).unwrap_err();
1156        assert!(
1157            err.to_string().contains("unused type alias"),
1158            "expected unused type alias error, got: {err}"
1159        );
1160    }
1161
1162    #[test]
1163    fn type_library_with_exported_types_and_params() {
1164        let source = "\
1165---
1166
1167name: types
1168types:
1169  - Labelled = enum(Known(label = str), Unknown)
1170  - Severity = enum(Informational, Low, Medium, High, Critical)
1171
1172params:
1173  - tasks = list(title = str, category = Labelled, component = Labelled)
1174  - post_types = list(tag = str)
1175
1176allow_unused: true
1177---
1178{# type library #}";
1179        let (fm, _) = parse_frontmatter(source).unwrap();
1180        assert_eq!(fm.declarations.len(), 2);
1181        // Labelled is used by tasks param, so it remains in type_aliases.
1182        assert!(fm.type_aliases.contains_key("Labelled"));
1183        // Severity is NOT used by any param, but allow_unused suppresses the error.
1184        // It remains in the explicit type_aliases map.
1185        assert!(
1186            fm.type_aliases.contains_key("Severity"),
1187            "Severity should remain in type_aliases with allow_unused: {:?}",
1188            fm.type_aliases.keys().collect::<Vec<_>>()
1189        );
1190    }
1191
1192    #[test]
1193    fn test_consts_referencing_previous_consts_in_list() {
1194        let source = "\
1195---
1196
1197name: test_const_ref
1198consts:
1199  - SCRATCH = str := \"scratch\"
1200  - EVIDENCE = str := \"evidence\"
1201  - DIRS = list(str) := [SCRATCH, EVIDENCE]
1202---
1203hello";
1204        let (fm, _) = parse_frontmatter(source).unwrap();
1205        assert_eq!(fm.consts.len(), 3);
1206        let dirs = fm.consts.iter().find(|d| d.name == "DIRS").unwrap();
1207        let val = dirs.default_value.as_ref().unwrap();
1208        match val {
1209            crate::value::Value::List(items) => {
1210                assert_eq!(items.len(), 2);
1211                assert_eq!(items[0], crate::value::Value::Str("scratch".to_string()));
1212                assert_eq!(items[1], crate::value::Value::Str("evidence".to_string()));
1213            }
1214            other => panic!("Expected List, got {other:?}"),
1215        }
1216    }
1217}