Skip to main content

md_tmpl/
types.rs

1//! Frontmatter type declarations and validation.
2
3use alloc::{
4    boxed::Box,
5    string::{String, ToString},
6    vec::Vec,
7};
8use core::fmt;
9
10use crate::value::Value;
11
12/// Expected type of a template variable.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum VarType {
15    /// `str` — expects a string value.
16    Str,
17    /// `bool` — expects a boolean value.
18    Bool,
19    /// `int` — expects an integer value.
20    Int,
21    /// `float` — expects a floating-point value.
22    Float,
23    /// `list(field = type, ...)` — required fields per item.
24    List(Vec<VarDecl>),
25    /// `struct(field = type, ...)` — required fields.
26    Struct(Vec<VarDecl>),
27    /// `enum(Option1, Option2, ...)` — expects one of these variants.
28    Enum(Vec<VariantDecl>),
29    /// `tmpl(field = type, ...)` — expects a template with matching params.
30    Tmpl(Vec<VarDecl>),
31    /// `option(T)` — syntactic sugar for `enum(Some(val = T), None)`.
32    /// Accepts `Value::None` or the inner `T` type directly.
33    Option(Box<VarType>),
34}
35
36/// Write a comma-separated `name = type` field list.
37fn fmt_fields(fields: &[VarDecl], f: &mut fmt::Formatter<'_>) -> fmt::Result {
38    for (i, decl) in fields.iter().enumerate() {
39        if i > 0 {
40            write!(f, ", ")?;
41        }
42        if decl.name.is_empty() {
43            write!(f, "{}", decl.var_type)?;
44        } else {
45            write!(f, "{} = {}", decl.name, decl.var_type)?;
46        }
47    }
48    Ok(())
49}
50
51impl fmt::Display for VarType {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            Self::Str => f.write_str(crate::consts::TYPE_STR),
55            Self::Bool => f.write_str(crate::consts::TYPE_BOOL),
56            Self::Int => f.write_str(crate::consts::TYPE_INT),
57            Self::Float => f.write_str(crate::consts::TYPE_FLOAT),
58            Self::List(fields) => {
59                f.write_str(crate::consts::TYPE_LIST_PREFIX)?;
60                fmt_fields(fields, f)?;
61                write!(f, ")")
62            }
63            Self::Struct(fields) => {
64                f.write_str(crate::consts::TYPE_STRUCT_PREFIX)?;
65                fmt_fields(fields, f)?;
66                write!(f, ")")
67            }
68            Self::Enum(variants) => {
69                // Detect desugared option(T) pattern and display as `option(T)`.
70                if let Some(inner_ty) = Self::detect_option_inner(variants) {
71                    write!(f, "{}{inner_ty})", crate::consts::TYPE_OPTION_PREFIX)
72                } else {
73                    f.write_str(crate::consts::TYPE_ENUM_PREFIX)?;
74                    for (i, var) in variants.iter().enumerate() {
75                        if i > 0 {
76                            write!(f, ", ")?;
77                        }
78                        write!(f, "{}", var.name)?;
79                        if !var.fields.is_empty() {
80                            write!(f, "(")?;
81                            fmt_fields(&var.fields, f)?;
82                            write!(f, ")")?;
83                        }
84                    }
85                    write!(f, ")")
86                }
87            }
88            Self::Tmpl(fields) => {
89                f.write_str(crate::consts::TYPE_TMPL_PREFIX)?;
90                fmt_fields(fields, f)?;
91                write!(f, ")")
92            }
93            Self::Option(inner) => write!(f, "{}{inner})", crate::consts::TYPE_OPTION_PREFIX),
94        }
95    }
96}
97
98impl VarType {
99    /// Returns `true` if this type can be directly displayed via `{{ expr }}`.
100    ///
101    /// Only scalar types (`str`, `int`, `float`, `bool`) are displayable.
102    /// Compound types (`list`, `struct`, `enum`, `tmpl`, `option`) must be
103    /// accessed through iteration, field access, `kind()`, `has()`, or
104    /// `{% match %}` instead.
105    #[must_use]
106    pub fn is_displayable(&self) -> bool {
107        matches!(self, Self::Str | Self::Int | Self::Float | Self::Bool)
108    }
109
110    /// Returns `true` if `value` is compatible with this declared type.
111    ///
112    /// - Scalar types match their corresponding `Value` variant.
113    /// - `List(fields)` matches `Value::List`; if `fields` is non-empty,
114    ///   **every** item must be a `Struct` with all required keys **and**
115    ///   matching value types (recursive).
116    /// - `Struct(fields)` matches `Value::Struct`; required keys must be present
117    ///   with matching value types (recursive).
118    /// - `Enum(variants)` matches unit variants as `Value::Str`, struct
119    ///   variants as `Value::Struct` with `__kind__` + typed fields.
120    #[must_use]
121    pub fn matches(&self, value: &Value) -> bool {
122        self.check(value).is_ok()
123    }
124
125    /// Validate `value` against this type, returning a structured error with
126    /// the path to the first mismatch on failure.
127    ///
128    /// Uses a two-pass strategy: a fast discriminant-only check first (zero
129    /// allocations), falling back to the full path-building check only when
130    /// a mismatch is detected.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`TypeCheckError`] with the dotted path to the mismatched field,
135    /// the expected type, the actual type, and a preview of the actual value.
136    pub fn check(&self, value: &Value) -> Result<(), TypeCheckError> {
137        // Fast path: discriminant-only check, zero allocations.
138        if self.check_fast(value) {
139            return Ok(());
140        }
141        // Slow path: build the full error path.
142        self.check_inner(value, String::new())
143    }
144
145    /// Fast discriminant-only type check — returns `true` if the value matches.
146    ///
147    /// This avoids all `String` allocations (no path building, no error
148    /// formatting) and is used as the first pass in [`check`](Self::check).
149    /// For deeply nested types with many list items, this is dramatically
150    /// faster than the full `check_inner` path on the success case.
151    #[inline]
152    fn check_fast(&self, value: &Value) -> bool {
153        match self {
154            Self::Str => matches!(value, Value::Str(_)),
155            Self::Bool => matches!(value, Value::Bool(_)),
156            Self::Int => matches!(value, Value::Int(_)),
157            Self::Float => matches!(value, Value::Float(_)),
158            Self::List(fields) => Self::check_fast_list(fields, value),
159            Self::Struct(fields) => Self::check_fast_struct(fields, value),
160            Self::Enum(variants) => Self::check_fast_enum(variants, value),
161            Self::Tmpl(expected) => Self::check_fast_tmpl(expected, value),
162            Self::Option(inner) => matches!(value, Value::None) || inner.check_fast(value),
163        }
164    }
165
166    /// Fast check for `List` types: each item must match the declared fields.
167    fn check_fast_list(fields: &[VarDecl], value: &Value) -> bool {
168        let Value::List(items) = value else {
169            return false;
170        };
171        if fields.is_empty() {
172            return true;
173        }
174        for item in items.iter() {
175            if fields.len() == 1 && fields[0].name.is_empty() {
176                if !fields[0].var_type.check_fast(item) {
177                    return false;
178                }
179                continue;
180            }
181            let Value::Struct(map) = item else {
182                return false;
183            };
184            if !Self::check_fast_struct_fields(fields, map) {
185                return false;
186            }
187        }
188        true
189    }
190
191    /// Fast check for `Struct` types: extract the map and delegate to field checking.
192    fn check_fast_struct(fields: &[VarDecl], value: &Value) -> bool {
193        let Value::Struct(map) = value else {
194            return false;
195        };
196        Self::check_fast_struct_fields(fields, map)
197    }
198
199    /// Shared struct field checking: every declared field must be present with a
200    /// matching value type (recursive).
201    fn check_fast_struct_fields(
202        fields: &[VarDecl],
203        map: &crate::compat::HashMap<String, Value>,
204    ) -> bool {
205        for decl in fields {
206            match map.get(&decl.name) {
207                Some(v) => {
208                    if !decl.var_type.check_fast(v) {
209                        return false;
210                    }
211                }
212                None => return false,
213            }
214        }
215        true
216    }
217
218    /// Fast check for `Enum` types: unit variants match strings, struct variants
219    /// match dicts with an `ENUM_TAG_KEY` field and typed fields.
220    fn check_fast_enum(variants: &[VariantDecl], value: &Value) -> bool {
221        match value {
222            Value::Str(s) => variants.iter().any(|v| v.name == *s && v.fields.is_empty()),
223            Value::Struct(map) => {
224                let tag_key = crate::consts::ENUM_TAG_KEY;
225                let Some(Value::Str(tag)) = map.get(tag_key) else {
226                    return false;
227                };
228                let Some(var) = variants.iter().find(|v| v.name == *tag) else {
229                    return false;
230                };
231                for decl in &var.fields {
232                    match map.get(&decl.name) {
233                        Some(v) => {
234                            if !decl.var_type.check_fast(v) {
235                                return false;
236                            }
237                        }
238                        None => return false,
239                    }
240                }
241                true
242            }
243            _ => false,
244        }
245    }
246
247    /// Fast check for `Tmpl` types: the template's parameters must match the
248    /// expected signature.
249    fn check_fast_tmpl(expected: &[VarDecl], value: &Value) -> bool {
250        let Value::Tmpl(tmpl) = value else {
251            return false;
252        };
253        let actual_decls = tmpl.declarations();
254        for exp in expected {
255            match actual_decls.iter().find(|d| d.name == exp.name) {
256                Some(act) => {
257                    if act.var_type != exp.var_type {
258                        return false;
259                    }
260                }
261                None => return false,
262            }
263        }
264        for act in actual_decls {
265            if act.default_value.is_none() && !expected.iter().any(|e| e.name == act.name) {
266                return false;
267            }
268        }
269        true
270    }
271
272    fn check_inner(&self, value: &Value, path: String) -> Result<(), TypeCheckError> {
273        match self {
274            Self::Str => {
275                if matches!(value, Value::Str(_)) {
276                    Ok(())
277                } else {
278                    Err(TypeCheckError::new(path, crate::consts::TYPE_STR, value))
279                }
280            }
281            Self::Bool => {
282                if matches!(value, Value::Bool(_)) {
283                    Ok(())
284                } else {
285                    Err(TypeCheckError::new(path, crate::consts::TYPE_BOOL, value))
286                }
287            }
288            Self::Int => {
289                if matches!(value, Value::Int(_)) {
290                    Ok(())
291                } else {
292                    Err(TypeCheckError::new(path, crate::consts::TYPE_INT, value))
293                }
294            }
295            Self::Float => {
296                if matches!(value, Value::Float(_)) {
297                    Ok(())
298                } else {
299                    Err(TypeCheckError::new(path, crate::consts::TYPE_FLOAT, value))
300                }
301            }
302            Self::List(fields) => Self::check_list(fields, value, path),
303            Self::Struct(fields) => Self::check_dict(fields, value, path),
304            Self::Enum(variants) => Self::check_enum(variants, value, path),
305            Self::Tmpl(params) => Self::check_tmpl(params, value, path),
306            Self::Option(inner) => {
307                if matches!(value, Value::None) {
308                    Ok(())
309                } else {
310                    inner.check_inner(value, path)
311                }
312            }
313        }
314    }
315
316    /// Validate a `List` type: each item must be a dict with all required fields.
317    fn check_list(fields: &[VarDecl], value: &Value, path: String) -> Result<(), TypeCheckError> {
318        let Value::List(items) = value else {
319            return Err(TypeCheckError::new(path, crate::consts::TYPE_LIST, value));
320        };
321        if fields.is_empty() {
322            return Ok(());
323        }
324        for (i, item) in items.iter().enumerate() {
325            if fields.len() == 1 && fields[0].name.is_empty() {
326                // Scalar list: each item must match the first field's type.
327                fields[0]
328                    .var_type
329                    .check_inner(item, format!("{path}[{i}]"))?;
330                continue;
331            }
332            let Value::Struct(map) = item else {
333                return Err(TypeCheckError::new(
334                    format!("{path}[{i}]"),
335                    crate::consts::TYPE_STRUCT,
336                    item,
337                ));
338            };
339            for decl in fields {
340                let field_path = if path.is_empty() {
341                    format!("[{i}].{}", decl.name)
342                } else {
343                    format!("{path}[{i}].{}", decl.name)
344                };
345                match map.get(&decl.name) {
346                    Some(v) => decl.var_type.check_inner(v, field_path)?,
347                    None => {
348                        return Err(TypeCheckError {
349                            path: field_path,
350                            expected: decl.var_type.to_string(),
351                            actual: "missing".into(),
352                            actual_value: String::new(),
353                        });
354                    }
355                }
356            }
357        }
358        Ok(())
359    }
360
361    /// Validate a `Struct` type: all required keys must be present with matching types.
362    fn check_dict(fields: &[VarDecl], value: &Value, path: String) -> Result<(), TypeCheckError> {
363        let Value::Struct(map) = value else {
364            return Err(TypeCheckError::new(path, crate::consts::TYPE_STRUCT, value));
365        };
366        for decl in fields {
367            let field_path = if path.is_empty() {
368                decl.name.clone()
369            } else {
370                format!("{path}.{}", decl.name)
371            };
372            match map.get(&decl.name) {
373                Some(v) => decl.var_type.check_inner(v, field_path)?,
374                None => {
375                    return Err(TypeCheckError {
376                        path: field_path,
377                        expected: decl.var_type.to_string(),
378                        actual: "missing".into(),
379                        actual_value: String::new(),
380                    });
381                }
382            }
383        }
384        Ok(())
385    }
386
387    /// Validate an `Enum` type: unit variants match strings, struct variants
388    /// match dicts with an `ENUM_TAG_KEY` field and typed fields.
389    fn check_enum(
390        variants: &[VariantDecl],
391        value: &Value,
392        path: String,
393    ) -> Result<(), TypeCheckError> {
394        match value {
395            Value::Str(s) => {
396                if variants.iter().any(|v| v.name == *s && v.fields.is_empty()) {
397                    Ok(())
398                } else {
399                    let variant_names: Vec<&str> =
400                        variants.iter().map(|v| v.name.as_str()).collect();
401                    Err(TypeCheckError {
402                        path,
403                        expected: format!("enum({})", variant_names.join(", ")),
404                        actual: format!("str({s})"),
405                        actual_value: s.clone(),
406                    })
407                }
408            }
409            Value::Struct(map) => {
410                let tag_key = crate::consts::ENUM_TAG_KEY;
411                let Some(Value::Str(tag)) = map.get(tag_key) else {
412                    return Err(TypeCheckError {
413                        path,
414                        expected: format!("enum dict with '{tag_key}' field"),
415                        actual: value.type_name().into(),
416                        actual_value: value.to_string(),
417                    });
418                };
419                let Some(var) = variants.iter().find(|v| v.name == *tag) else {
420                    let variant_names: Vec<&str> =
421                        variants.iter().map(|v| v.name.as_str()).collect();
422                    return Err(TypeCheckError {
423                        path: format!("{path}.{tag_key}"),
424                        expected: format!("one of [{}]", variant_names.join(", ")),
425                        actual: format!("'{tag}'"),
426                        actual_value: tag.clone(),
427                    });
428                };
429                for decl in &var.fields {
430                    let field_path = if path.is_empty() {
431                        decl.name.clone()
432                    } else {
433                        format!("{path}.{}", decl.name)
434                    };
435                    match map.get(&decl.name) {
436                        Some(v) => decl.var_type.check_inner(v, field_path)?,
437                        None => {
438                            return Err(TypeCheckError {
439                                path: field_path,
440                                expected: decl.var_type.to_string(),
441                                actual: "missing".into(),
442                                actual_value: String::new(),
443                            });
444                        }
445                    }
446                }
447                Ok(())
448            }
449            _ => Err(TypeCheckError::new(
450                path,
451                &VarType::Enum(variants.to_vec()).to_string(),
452                value,
453            )),
454        }
455    }
456
457    /// Validate a `Tmpl` type: the value must be a template whose parameters
458    /// match the expected signature.
459    fn check_tmpl(expected: &[VarDecl], value: &Value, path: String) -> Result<(), TypeCheckError> {
460        let Value::Tmpl(tmpl) = value else {
461            return Err(TypeCheckError::new(path, crate::consts::TYPE_TMPL, value));
462        };
463
464        // Check if the template's parameters match the expected signature.
465        // Rule: The template must accept ALL parameters defined in the signature
466        // with matching types. It may have additional parameters IF they have
467        // default values.
468        let actual_decls = tmpl.declarations();
469
470        for exp in expected {
471            let found = actual_decls.iter().find(|d| d.name == exp.name);
472            match found {
473                Some(act) => {
474                    if act.var_type != exp.var_type {
475                        return Err(TypeCheckError {
476                            path: if path.is_empty() {
477                                exp.name.clone()
478                            } else {
479                                format!("{path}.{}", exp.name)
480                            },
481                            expected: exp.var_type.to_string(),
482                            actual: act.var_type.to_string(),
483                            actual_value: String::new(),
484                        });
485                    }
486                }
487                None => {
488                    return Err(TypeCheckError {
489                        path: if path.is_empty() {
490                            exp.name.clone()
491                        } else {
492                            format!("{path}.{}", exp.name)
493                        },
494                        expected: exp.var_type.to_string(),
495                        actual: "missing".into(),
496                        actual_value: String::new(),
497                    });
498                }
499            }
500        }
501
502        // Also check if the template has any REQUIRED parameters not in the signature.
503        for act in actual_decls {
504            if act.default_value.is_none() && !expected.iter().any(|e| e.name == act.name) {
505                return Err(TypeCheckError {
506                    path: if path.is_empty() {
507                        act.name.clone()
508                    } else {
509                        format!("{path}.{}", act.name)
510                    },
511                    expected: "in signature".into(),
512                    actual: "missing".into(),
513                    actual_value: String::new(),
514                });
515            }
516        }
517
518        Ok(())
519    }
520}
521
522/// Structured error from [`VarType::check`] with the path to the mismatch.
523#[derive(Debug, Clone)]
524pub struct TypeCheckError {
525    /// Dotted path to the mismatched field (e.g. `"tasks[2].title"`).
526    pub path: String,
527    /// The expected type at that path.
528    pub expected: String,
529    /// The actual type found.
530    pub actual: String,
531    /// Preview of the actual value.
532    pub actual_value: String,
533}
534
535/// Maximum length for the actual-value preview in error messages.
536const MAX_PREVIEW_LEN: usize = 60;
537
538impl TypeCheckError {
539    fn new(path: String, expected: &str, value: &Value) -> Self {
540        let preview = value.to_string();
541        let actual_value = if preview.len() > MAX_PREVIEW_LEN {
542            // Truncate at a character boundary to avoid panicking on multi-byte UTF-8.
543            let truncate_at = preview
544                .char_indices()
545                .map(|(i, _)| i)
546                .take_while(|&i| i <= MAX_PREVIEW_LEN - 3)
547                .last()
548                .unwrap_or(0);
549            format!("{}…", &preview[..truncate_at])
550        } else {
551            preview
552        };
553        Self {
554            path,
555            expected: expected.into(),
556            actual: value.type_name().into(),
557            actual_value,
558        }
559    }
560}
561
562impl fmt::Display for TypeCheckError {
563    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564        if self.path.is_empty() {
565            write!(f, "expected {}, got {}", self.expected, self.actual)?;
566        } else {
567            write!(
568                f,
569                "at '{}': expected {}, got {}",
570                self.path, self.expected, self.actual
571            )?;
572        }
573        if !self.actual_value.is_empty() {
574            write!(f, " ({})", self.actual_value)?;
575        }
576        Ok(())
577    }
578}
579
580/// A variant declaration inside an enum type.
581#[derive(Debug, Clone, PartialEq, Eq)]
582pub struct VariantDecl {
583    /// Variant name.
584    pub name: String,
585    /// Optional associated fields for struct variants.
586    pub fields: Vec<VarDecl>,
587}
588
589/// A variable declaration: name + type + optional default.
590#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct VarDecl {
592    /// Variable name.
593    pub name: String,
594    /// Expected type.
595    pub var_type: VarType,
596    /// Optional default value for this parameter (or mandatory value for a constant).
597    pub default_value: Option<crate::value::Value>,
598}
599
600impl VarDecl {
601    /// Returns the default value for this declaration, if any.
602    #[must_use]
603    pub fn default_value(&self) -> Option<&crate::value::Value> {
604        self.default_value.as_ref()
605    }
606}
607
608impl VarType {
609    /// Returns `true` if this type is an `option(T)`, either as the dedicated
610    /// `Option` variant or the desugared `enum(Some(val = T), None)` form.
611    #[must_use]
612    pub fn is_option(&self) -> bool {
613        match self {
614            VarType::Option(_) => true,
615            VarType::Enum(v) => Self::detect_option_inner(v).is_some(),
616            _ => false,
617        }
618    }
619
620    /// If this type is `option(T)`, returns the inner `T` type.
621    #[must_use]
622    pub fn option_inner_type(&self) -> Option<&VarType> {
623        match self {
624            VarType::Option(inner) => Some(inner),
625            VarType::Enum(variants) => Self::detect_option_inner(variants),
626            _ => None,
627        }
628    }
629
630    /// Detect the `option(T)` pattern: exactly two variants named `Some` and
631    /// `None`, where `Some` has exactly one field named `val` and `None` has
632    /// no fields.
633    fn detect_option_inner(variants: &[VariantDecl]) -> Option<&VarType> {
634        use crate::consts::{OPTION_NONE, OPTION_SOME, OPTION_VAL_FIELD};
635        if variants.len() != 2 {
636            return None;
637        }
638        let (some, none) = if variants[0].name == OPTION_SOME && variants[1].name == OPTION_NONE {
639            (&variants[0], &variants[1])
640        } else {
641            return None;
642        };
643        if !none.fields.is_empty() {
644            return None;
645        }
646        if some.fields.len() != 1 || some.fields[0].name != OPTION_VAL_FIELD {
647            return None;
648        }
649        Some(&some.fields[0].var_type)
650    }
651}
652
653// ---------------------------------------------------------------------------
654// Built-in type names
655// ---------------------------------------------------------------------------
656
657/// Names of all built-in types. Used for shadowing checks in validation.
658pub const BUILTIN_TYPE_NAMES: &[&str] = &[
659    crate::consts::TYPE_STR,
660    crate::consts::TYPE_BOOL,
661    crate::consts::TYPE_INT,
662    crate::consts::TYPE_FLOAT,
663    crate::consts::TYPE_LIST,
664    crate::consts::TYPE_STRUCT,
665    crate::consts::TYPE_ENUM,
666    crate::consts::TYPE_TMPL,
667    crate::consts::TYPE_OPTION,
668];
669
670// ---------------------------------------------------------------------------
671// PascalCase conversion
672// ---------------------------------------------------------------------------
673
674/// Convert a `snake_case`, `kebab-case`, or other string to `PascalCase`.
675///
676/// Splits on `_` and `-`, capitalises the first character of each segment,
677/// and preserves the remaining characters.
678///
679/// # Examples
680///
681/// ```
682/// use md_tmpl::to_pascal_case;
683/// assert_eq!(to_pascal_case("code_review"), "CodeReview");
684/// assert_eq!(to_pascal_case("task-report"), "TaskReport");
685/// ```
686#[must_use]
687pub fn to_pascal_case(s: &str) -> String {
688    s.split(['_', '-'])
689        .filter(|part| !part.is_empty())
690        .map(|part| {
691            let mut chars = part.chars();
692            match chars.next() {
693                Some(first) => {
694                    let upper: String = first.to_uppercase().collect();
695                    format!("{upper}{}", chars.as_str())
696                }
697                None => String::new(),
698            }
699        })
700        .collect()
701}
702
703// ---------------------------------------------------------------------------
704// Tests
705// ---------------------------------------------------------------------------
706
707#[cfg(all(test, feature = "std"))]
708mod tests {
709    use std::sync::Arc;
710
711    use super::*;
712    use crate::{compat::HashMap, consts::ENUM_TAG_KEY};
713
714    // -- Display --
715
716    #[test]
717    fn display_scalar_types() {
718        assert_eq!(VarType::Str.to_string(), "str");
719        assert_eq!(VarType::Bool.to_string(), "bool");
720        assert_eq!(VarType::Int.to_string(), "int");
721        assert_eq!(VarType::Float.to_string(), "float");
722    }
723
724    #[test]
725    fn display_list_with_fields() {
726        let var_type = VarType::List(vec![
727            VarDecl {
728                name: "name".into(),
729                var_type: VarType::Str,
730                default_value: None,
731            },
732            VarDecl {
733                name: "score".into(),
734                var_type: VarType::Int,
735                default_value: None,
736            },
737        ]);
738        assert_eq!(var_type.to_string(), "list(name = str, score = int)");
739    }
740
741    #[test]
742    fn display_struct_with_fields() {
743        let var_type = VarType::Struct(vec![VarDecl {
744            name: "label".into(),
745            var_type: VarType::Str,
746            default_value: None,
747        }]);
748        assert_eq!(var_type.to_string(), "struct(label = str)");
749    }
750
751    // -- matches --
752
753    #[test]
754    fn str_matches_str_only() {
755        assert!(VarType::Str.matches(&Value::Str("hello".into())));
756        assert!(!VarType::Str.matches(&Value::Bool(true)));
757        assert!(!VarType::Str.matches(&Value::Int(1)));
758    }
759
760    #[test]
761    fn bool_matches_bool_only() {
762        assert!(VarType::Bool.matches(&Value::Bool(false)));
763        assert!(!VarType::Bool.matches(&Value::Str("true".into())));
764    }
765
766    #[test]
767    fn int_matches_int_only() {
768        assert!(VarType::Int.matches(&Value::Int(42)));
769        assert!(!VarType::Int.matches(&Value::Float(42.0)));
770    }
771
772    #[test]
773    fn float_matches_float_only() {
774        assert!(VarType::Float.matches(&Value::Float(3.25)));
775        assert!(!VarType::Float.matches(&Value::Int(3)));
776    }
777
778    #[test]
779    fn list_no_fields_matches_any_list() {
780        assert!(VarType::List(vec![]).matches(&Value::List(Arc::new(vec![]))));
781        assert!(VarType::List(vec![]).matches(&Value::List(Arc::new(vec![Value::Int(1)]))));
782        assert!(!VarType::List(vec![]).matches(&Value::Str("x".into())));
783    }
784
785    #[test]
786    fn list_with_fields_validates_all_items() {
787        let var_type = VarType::List(vec![VarDecl {
788            name: "name".into(),
789            var_type: VarType::Str,
790            default_value: None,
791        }]);
792
793        // Empty list passes (nothing to validate).
794        assert!(var_type.matches(&Value::List(Arc::new(vec![]))));
795
796        // Single valid item.
797        let valid_item = Value::Struct(Arc::new(HashMap::from([(
798            "name".into(),
799            Value::Str("a".into()),
800        )])));
801        assert!(var_type.matches(&Value::List(Arc::new(vec![valid_item]))));
802
803        // Missing key in first item.
804        let invalid_item = Value::Struct(Arc::new(HashMap::from([("id".into(), Value::Int(1))])));
805        assert!(!var_type.matches(&Value::List(Arc::new(vec![invalid_item]))));
806
807        // First item is not a Struct.
808        assert!(!var_type.matches(&Value::List(Arc::new(vec![Value::Int(1)]))));
809    }
810
811    #[test]
812    fn list_with_fields_rejects_wrong_value_type() {
813        let var_type = VarType::List(vec![VarDecl {
814            name: "name".into(),
815            var_type: VarType::Str,
816            default_value: None,
817        }]);
818
819        // Key present but wrong type (Int instead of Str).
820        let wrong_type = Value::Struct(Arc::new(HashMap::from([("name".into(), Value::Int(42))])));
821        assert!(
822            !var_type.matches(&Value::List(Arc::new(vec![wrong_type]))),
823            "should reject list item where 'name' is int, not str"
824        );
825    }
826
827    #[test]
828    fn list_validates_all_items_not_just_first() {
829        let var_type = VarType::List(vec![VarDecl {
830            name: "name".into(),
831            var_type: VarType::Str,
832            default_value: None,
833        }]);
834
835        let good = Value::Struct(Arc::new(HashMap::from([(
836            "name".into(),
837            Value::Str("ok".into()),
838        )])));
839        let bad = Value::Struct(Arc::new(HashMap::from([("name".into(), Value::Int(99))])));
840
841        // First item good, second bad → reject.
842        assert!(
843            !var_type.matches(&Value::List(Arc::new(vec![good.clone(), bad]))),
844            "should validate ALL items, not just the first"
845        );
846
847        // Both good → accept.
848        assert!(var_type.matches(&Value::List(Arc::new(vec![good.clone(), good]))));
849    }
850
851    #[test]
852    fn struct_validates_required_keys_and_types() {
853        let var_type = VarType::Struct(vec![
854            VarDecl {
855                name: "title".into(),
856                var_type: VarType::Str,
857                default_value: None,
858            },
859            VarDecl {
860                name: "count".into(),
861                var_type: VarType::Int,
862                default_value: None,
863            },
864        ]);
865
866        let valid = Value::Struct(Arc::new(HashMap::from([
867            ("title".into(), Value::Str("task".into())),
868            ("count".into(), Value::Int(5)),
869        ])));
870        assert!(var_type.matches(&valid));
871
872        // Missing "count".
873        let missing_field = Value::Struct(Arc::new(HashMap::from([(
874            "title".into(),
875            Value::Str("task".into()),
876        )])));
877        assert!(!var_type.matches(&missing_field));
878
879        // Not a dict at all.
880        assert!(!var_type.matches(&Value::Str("oops".into())));
881    }
882
883    #[test]
884    fn struct_rejects_wrong_field_type() {
885        let var_type = VarType::Struct(vec![VarDecl {
886            name: "count".into(),
887            var_type: VarType::Int,
888            default_value: None,
889        }]);
890
891        // Key present but wrong type (Str instead of Int).
892        let wrong = Value::Struct(Arc::new(HashMap::from([(
893            "count".into(),
894            Value::Str("five".into()),
895        )])));
896        assert!(
897            !var_type.matches(&wrong),
898            "should reject struct where 'count' is str, not int"
899        );
900    }
901
902    #[test]
903    fn struct_nested_type_checking() {
904        // struct(meta = struct(version = int))
905        let var_type = VarType::Struct(vec![VarDecl {
906            name: "meta".into(),
907            var_type: VarType::Struct(vec![VarDecl {
908                name: "version".into(),
909                var_type: VarType::Int,
910                default_value: None,
911            }]),
912            default_value: None,
913        }]);
914
915        let valid = Value::Struct(Arc::new(HashMap::from([(
916            "meta".into(),
917            Value::Struct(Arc::new(HashMap::from([("version".into(), Value::Int(3))]))),
918        )])));
919        assert!(var_type.matches(&valid));
920
921        // Nested field wrong type.
922        let wrong = Value::Struct(Arc::new(HashMap::from([(
923            "meta".into(),
924            Value::Struct(Arc::new(HashMap::from([(
925                "version".into(),
926                Value::Str("3".into()),
927            )]))),
928        )])));
929        assert!(
930            !var_type.matches(&wrong),
931            "should recursively check nested struct field types"
932        );
933    }
934
935    #[test]
936    fn struct_no_fields_matches_any_dict() {
937        assert!(VarType::Struct(vec![]).matches(&Value::Struct(Arc::new(HashMap::new()))));
938        assert!(!VarType::Struct(vec![]).matches(&Value::List(Arc::new(vec![]))));
939    }
940
941    #[test]
942    fn display_enum_with_fields() {
943        let var_type = VarType::Enum(vec![
944            VariantDecl {
945                name: "Confirmed".into(),
946                fields: vec![VarDecl {
947                    name: "evidence".into(),
948                    var_type: VarType::Str,
949                    default_value: None,
950                }],
951            },
952            VariantDecl {
953                name: "Inconclusive".into(),
954                fields: vec![],
955            },
956        ]);
957        assert_eq!(
958            var_type.to_string(),
959            "enum(Confirmed(evidence = str), Inconclusive)"
960        );
961    }
962
963    #[test]
964    fn enum_matches_validation() {
965        let var_type = VarType::Enum(vec![
966            VariantDecl {
967                name: "Confirmed".into(),
968                fields: vec![VarDecl {
969                    name: "evidence".into(),
970                    var_type: VarType::Str,
971                    default_value: None,
972                }],
973            },
974            VariantDecl {
975                name: "Inconclusive".into(),
976                fields: vec![],
977            },
978        ]);
979
980        // String value matching unit variant
981        assert!(var_type.matches(&Value::Str("Inconclusive".into())));
982        assert!(!var_type.matches(&Value::Str("Confirmed".into())));
983
984        // Internally tagged dict matching struct variant
985        let valid_dict = Value::Struct(Arc::new(HashMap::from([
986            (ENUM_TAG_KEY.into(), Value::Str("Confirmed".into())),
987            ("evidence".into(), Value::Str("some evidence".into())),
988        ])));
989        assert!(var_type.matches(&valid_dict));
990
991        // Missing required field
992        let missing_field = Value::Struct(Arc::new(HashMap::from([(
993            ENUM_TAG_KEY.into(),
994            Value::Str("Confirmed".into()),
995        )])));
996        assert!(!var_type.matches(&missing_field));
997
998        // Invalid variant name
999        let invalid_variant = Value::Struct(Arc::new(HashMap::from([(
1000            ENUM_TAG_KEY.into(),
1001            Value::Str("Unknown".into()),
1002        )])));
1003        assert!(!var_type.matches(&invalid_variant));
1004    }
1005
1006    #[test]
1007    fn enum_rejects_wrong_field_type() {
1008        let var_type = VarType::Enum(vec![VariantDecl {
1009            name: "Confirmed".into(),
1010            fields: vec![VarDecl {
1011                name: "evidence".into(),
1012                var_type: VarType::Str,
1013                default_value: None,
1014            }],
1015        }]);
1016
1017        // Field present but wrong type (Int instead of Str).
1018        let wrong = Value::Struct(Arc::new(HashMap::from([
1019            (ENUM_TAG_KEY.into(), Value::Str("Confirmed".into())),
1020            ("evidence".into(), Value::Int(42)),
1021        ])));
1022        assert!(
1023            !var_type.matches(&wrong),
1024            "should reject enum variant where 'evidence' is int, not str"
1025        );
1026    }
1027
1028    // -- check() path diagnostics --
1029
1030    #[test]
1031    fn check_scalar_error_has_empty_path() {
1032        let err = VarType::Int.check(&Value::Str("oops".into())).unwrap_err();
1033        assert!(
1034            err.path.is_empty(),
1035            "scalar mismatch should have empty path"
1036        );
1037        assert_eq!(err.expected, "int");
1038        assert_eq!(err.actual, "str");
1039    }
1040
1041    #[test]
1042    fn check_list_item_field_path() {
1043        let var_type = VarType::List(vec![VarDecl {
1044            name: "score".into(),
1045            var_type: VarType::Int,
1046            default_value: None,
1047        }]);
1048        // Second item has wrong type for score.
1049        let items = Value::List(Arc::new(vec![
1050            Value::Struct(Arc::new(HashMap::from([("score".into(), Value::Int(10))]))),
1051            Value::Struct(Arc::new(HashMap::from([(
1052                "score".into(),
1053                Value::Str("bad".into()),
1054            )]))),
1055        ]));
1056        let err = var_type.check(&items).unwrap_err();
1057        assert_eq!(err.path, "[1].score", "should point to items[1].score");
1058        assert_eq!(err.expected, "int");
1059    }
1060
1061    #[test]
1062    fn check_dict_missing_field_path() {
1063        let var_type = VarType::Struct(vec![VarDecl {
1064            name: "title".into(),
1065            var_type: VarType::Str,
1066            default_value: None,
1067        }]);
1068        let value = Value::Struct(Arc::new(HashMap::new())); // missing 'title'
1069        let err = var_type.check(&value).unwrap_err();
1070        assert_eq!(err.path, "title");
1071        assert_eq!(err.actual, "missing");
1072    }
1073
1074    #[test]
1075    fn check_nested_dict_path() {
1076        let var_type = VarType::Struct(vec![VarDecl {
1077            name: "meta".into(),
1078            var_type: VarType::Struct(vec![VarDecl {
1079                name: "version".into(),
1080                var_type: VarType::Int,
1081                default_value: None,
1082            }]),
1083            default_value: None,
1084        }]);
1085        let value = Value::Struct(Arc::new(HashMap::from([(
1086            "meta".into(),
1087            Value::Struct(Arc::new(HashMap::from([(
1088                "version".into(),
1089                Value::Str("3".into()),
1090            )]))),
1091        )])));
1092        let err = var_type.check(&value).unwrap_err();
1093        assert_eq!(err.path, "meta.version", "should show nested path");
1094    }
1095
1096    #[test]
1097    fn check_enum_invalid_tag_path() {
1098        let var_type = VarType::Enum(vec![VariantDecl {
1099            name: "Confirmed".into(),
1100            fields: vec![],
1101        }]);
1102        let value = Value::Struct(Arc::new(HashMap::from([(
1103            ENUM_TAG_KEY.into(),
1104            Value::Str("Unknown".into()),
1105        )])));
1106        let err = var_type.check(&value).unwrap_err();
1107        assert_eq!(err.path, format!(".{ENUM_TAG_KEY}"));
1108    }
1109
1110    #[test]
1111    fn check_display_with_path() {
1112        let err = TypeCheckError {
1113            path: "tasks[2].title".into(),
1114            expected: "str".into(),
1115            actual: "int".into(),
1116            actual_value: "42".into(),
1117        };
1118        assert_eq!(
1119            err.to_string(),
1120            "at 'tasks[2].title': expected str, got int (42)"
1121        );
1122    }
1123
1124    #[test]
1125    fn check_display_empty_path() {
1126        let err = TypeCheckError {
1127            path: String::new(),
1128            expected: "str".into(),
1129            actual: "int".into(),
1130            actual_value: "42".into(),
1131        };
1132        assert_eq!(err.to_string(), "expected str, got int (42)");
1133    }
1134
1135    // -- to_pascal_case tests -------------------------------------------------
1136
1137    #[test]
1138    fn pascal_case_snake_case() {
1139        assert_eq!(super::to_pascal_case("code_review"), "CodeReview");
1140        assert_eq!(super::to_pascal_case("simple_greeting"), "SimpleGreeting");
1141    }
1142
1143    #[test]
1144    fn pascal_case_kebab_case() {
1145        assert_eq!(super::to_pascal_case("task-report"), "TaskReport");
1146    }
1147
1148    #[test]
1149    fn pascal_case_single_word() {
1150        assert_eq!(super::to_pascal_case("single"), "Single");
1151    }
1152
1153    #[test]
1154    fn pascal_case_empty() {
1155        assert_eq!(super::to_pascal_case(""), "");
1156    }
1157
1158    #[test]
1159    fn pascal_case_mixed() {
1160        assert_eq!(
1161            super::to_pascal_case("already_PascalCase"),
1162            "AlreadyPascalCase"
1163        );
1164    }
1165
1166    #[test]
1167    fn pascal_case_leading_trailing_separators() {
1168        assert_eq!(super::to_pascal_case("_leading"), "Leading");
1169        assert_eq!(super::to_pascal_case("trailing_"), "Trailing");
1170        assert_eq!(super::to_pascal_case("__double__"), "Double");
1171    }
1172
1173    // -- BUILTIN_TYPE_NAMES tests ---------------------------------------------
1174
1175    #[test]
1176    fn builtin_type_names_contains_all_expected() {
1177        for name in &[
1178            "str", "bool", "int", "float", "list", "struct", "enum", "option",
1179        ] {
1180            assert!(
1181                super::BUILTIN_TYPE_NAMES.contains(name),
1182                "BUILTIN_TYPE_NAMES should contain '{name}'"
1183            );
1184        }
1185    }
1186}