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