Skip to main content

prosaic_core/
template.rs

1#[cfg(not(feature = "std"))]
2use alloc::format;
3#[cfg(not(feature = "std"))]
4use alloc::string::{String, ToString};
5#[cfg(not(feature = "std"))]
6use alloc::vec::Vec;
7
8use crate::error::ProsaicError;
9use prosaic_common::{PipeSpec, ValueType, pipe_spec, types_compatible};
10
11/// An argument passed to a pipe transform.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum PipeArg {
14    String(String),
15    Number(usize),
16}
17
18/// A pipe transform applied to a slot value.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Pipe {
21    pub name: String,
22    pub arg: Option<PipeArg>,
23}
24
25/// A segment of a parsed template.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Segment {
28    /// Literal text, rendered as-is.
29    Literal(String),
30    /// A slot referencing a context key, with optional pipe transforms.
31    Slot { key: String, pipes: Vec<Pipe> },
32    /// A conditional section: only renders if the condition key is truthy.
33    /// Truthy means: non-zero number, non-empty list, non-empty string.
34    Conditional {
35        condition_key: String,
36        inner: Vec<Segment>,
37    },
38    /// A partial inclusion `{>name}` — expands at render time using a
39    /// partial registered via `engine.register_partial`. Enables sharing
40    /// template fragments across many templates (e.g. a trailing
41    /// "affecting N consumers" clause reused across vocab entries).
42    Partial { name: String },
43}
44
45/// A parsed template ready for rendering.
46#[derive(Debug, Clone)]
47pub struct Template {
48    pub source: String,
49    pub segments: Vec<Segment>,
50}
51
52impl Template {
53    /// Parse a template string into segments.
54    ///
55    /// Syntax:
56    /// - `{key}` — substitute value from context
57    /// - `{key|pipe}` — apply a pipe transform
58    /// - `{key|pipe:arg}` — pipe with an argument
59    /// - `{key|pipe1|pipe2:arg}` — chained pipes
60    /// - `{?key}...{/?}` — conditional section (renders only if `key` is truthy)
61    pub fn parse(source: &str) -> Result<Self, ProsaicError> {
62        let segments = parse_segments(source, 0, source.len())?;
63        Ok(Template {
64            source: source.to_string(),
65            segments,
66        })
67    }
68
69    /// Return the text of every literal segment in this template.
70    ///
71    /// Walks the segment tree recursively, collecting text from
72    /// literal nodes at every nesting depth (including
73    /// inside conditional sections). Partial-inclusion nodes are
74    /// treated as opaque — their literals are only reachable after the
75    /// engine expands them at render time. Callers that need partial
76    /// content to contribute to faithfulness scoring should pre-expand
77    /// partials or disable the gate for partial-heavy templates.
78    ///
79    /// Used by faithfulness scoring to include template boilerplate in
80    /// the entailment source set alongside context values.
81    pub fn literal_tokens(&self) -> Vec<&str> {
82        let mut out = Vec::new();
83        collect_literals(&self.segments, &mut out);
84        out
85    }
86
87    /// Every slot key referenced by this template, including condition keys
88    /// from conditional sections (`{?key}...{/?}`).
89    ///
90    /// Walks the segment tree recursively. Partial nodes are skipped — their
91    /// slot keys are only reachable after the engine expands them at render time.
92    /// The returned list may contain duplicates (e.g. when a key appears in both
93    /// a conditional guard and its body). Used by the `prosaic_template!` proc macro
94    /// for compile-time slot validation.
95    pub fn slot_keys(&self) -> Vec<String> {
96        let mut out = Vec::new();
97        collect_slot_keys(&self.segments, &mut out);
98        out
99    }
100
101    /// Every pipe name referenced by any slot in this template.
102    ///
103    /// Walks the segment tree recursively. Returns the pipe name only (not any
104    /// argument, e.g. `"pluralize"` for `{count|pluralize:item}`). May contain
105    /// duplicates if the same pipe appears more than once. Used by the
106    /// `prosaic_template!` proc macro for compile-time pipe validation.
107    pub fn pipe_names(&self) -> Vec<String> {
108        let mut out = Vec::new();
109        collect_pipe_names(&self.segments, &mut out);
110        out
111    }
112
113    /// Every partial name referenced by this template via `{>name}`.
114    ///
115    /// Walks the segment tree recursively. Used by the engine at
116    /// `register_partial` time to detect direct and indirect cycles
117    /// before they can produce a stack overflow at render time.
118    pub fn partial_names(&self) -> Vec<String> {
119        let mut out = Vec::new();
120        collect_partial_names(&self.segments, &mut out);
121        out
122    }
123
124    /// Infer the [`ValueType`] required for each slot, based on pipe-chain flow.
125    ///
126    /// Walks every slot (including condition keys in `{?key}...{/?}` and
127    /// slots nested inside conditionals). For each slot:
128    /// - If it is used bare, its inferred type is `Any`.
129    /// - If its first pipe has input type `T`, the slot type is `T`.
130    /// - Every downstream pipe's input must match the previous pipe's output
131    ///   (using [`types_compatible`]); mismatch returns an `Err`.
132    /// - When a slot appears multiple times, the inferred types are **unified**
133    ///   by intersection: `Any ∩ T → T`, `T ∩ T → T`, and two distinct
134    ///   concrete types produce an `Err`.
135    ///
136    /// Unknown pipe names produce an `Err`. Slots inside `{>partial}` are
137    /// skipped (partials are opaque at parse time).
138    ///
139    /// Returns a `Vec<(slot_name, inferred_type)>` in unspecified order on
140    /// success, or a human-readable error string on conflict.
141    pub fn infer_types(&self) -> Result<Vec<(String, ValueType)>, String> {
142        let mut by_slot: Vec<(String, ValueType)> = Vec::new();
143        infer_segments(&self.segments, &mut by_slot)?;
144        Ok(by_slot)
145    }
146
147    /// Decompose this template into bare segments (literal text and bare slot
148    /// references with no pipes), returning `None` if the template contains
149    /// any pipes, conditional sections, or partial inclusions.
150    ///
151    /// Used by the `prosaic_template_compiled!` proc macro for compile-time
152    /// code generation. Not intended for general use.
153    pub fn as_bare_slots(&self) -> Option<Vec<BareSegment<'_>>> {
154        let mut out = Vec::new();
155        for seg in &self.segments {
156            match seg {
157                Segment::Literal(s) => out.push(BareSegment::Text(s.as_str())),
158                Segment::Slot { pipes, key } if pipes.is_empty() => {
159                    out.push(BareSegment::Slot(key.as_str()));
160                }
161                // Pipes, conditionals, or partials → not a bare-slot template.
162                _ => return None,
163            }
164        }
165        Some(out)
166    }
167}
168
169/// A segment from a bare-slot-only template decomposition.
170///
171/// Produced by [`Template::as_bare_slots`]. Used by the
172/// `prosaic_template_compiled!` proc macro for compile-time code generation.
173/// Not intended for general use outside the macro crate.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum BareSegment<'a> {
176    /// A literal text run.
177    Text(&'a str),
178    /// A bare slot reference (no pipes).
179    Slot(&'a str),
180}
181
182/// Recursively collect literal text from a segment list into `out`.
183/// `Segment::Partial` nodes are skipped (opaque at parse time).
184fn collect_literals<'a>(segments: &'a [Segment], out: &mut Vec<&'a str>) {
185    for seg in segments {
186        match seg {
187            Segment::Literal(s) => out.push(s.as_str()),
188            Segment::Slot { .. } => {}
189            Segment::Conditional { inner, .. } => collect_literals(inner, out),
190            Segment::Partial { .. } => {}
191        }
192    }
193}
194
195/// Recursively collect slot keys (and conditional condition keys) from a segment
196/// list into `out`. `Segment::Partial` nodes are skipped (opaque at parse time).
197fn collect_slot_keys(segments: &[Segment], out: &mut Vec<String>) {
198    for seg in segments {
199        match seg {
200            Segment::Slot { key, .. } => out.push(key.clone()),
201            Segment::Conditional {
202                condition_key,
203                inner,
204            } => {
205                out.push(condition_key.clone());
206                collect_slot_keys(inner, out);
207            }
208            Segment::Literal(_) | Segment::Partial { .. } => {}
209        }
210    }
211}
212
213/// Recursively collect partial names referenced by `{>name}` segments.
214/// Nested partial references (inside conditionals) are included.
215fn collect_partial_names(segments: &[Segment], out: &mut Vec<String>) {
216    for seg in segments {
217        match seg {
218            Segment::Partial { name } => out.push(name.clone()),
219            Segment::Conditional { inner, .. } => collect_partial_names(inner, out),
220            Segment::Literal(_) | Segment::Slot { .. } => {}
221        }
222    }
223}
224
225/// Recursively collect pipe names from all slot segments into `out`.
226/// `Segment::Partial` nodes are skipped (opaque at parse time).
227fn collect_pipe_names(segments: &[Segment], out: &mut Vec<String>) {
228    for seg in segments {
229        match seg {
230            Segment::Slot { pipes, .. } => {
231                for pipe in pipes {
232                    out.push(pipe.name.clone());
233                }
234            }
235            Segment::Conditional { inner, .. } => collect_pipe_names(inner, out),
236            Segment::Literal(_) | Segment::Partial { .. } => {}
237        }
238    }
239}
240
241/// Parse a range of source into segments. Handles nested conditionals.
242fn parse_segments(source: &str, start: usize, end: usize) -> Result<Vec<Segment>, ProsaicError> {
243    let mut segments = Vec::new();
244    let slice = &source[start..end];
245    let bytes = slice.as_bytes();
246    let mut i: usize = 0;
247    let mut literal_start: usize = 0;
248
249    while i < bytes.len() {
250        if bytes[i] != b'{' {
251            i += 1;
252            continue;
253        }
254
255        // Flush accumulated literal
256        if i > literal_start {
257            segments.push(Segment::Literal(slice[literal_start..i].to_string()));
258        }
259
260        let content_start = i + 1;
261        let is_conditional = content_start < bytes.len() && bytes[content_start] == b'?';
262        let is_partial = content_start < bytes.len() && bytes[content_start] == b'>';
263        let is_closing = content_start + 1 < bytes.len()
264            && bytes[content_start] == b'/'
265            && bytes[content_start + 1] == b'?';
266
267        if is_closing {
268            return Err(ProsaicError::TemplateParseError {
269                template: source.to_string(),
270                position: start + i,
271                reason: "unexpected closing `{/?}` without opening".to_string(),
272            });
273        }
274
275        if is_partial {
276            let name_start = content_start + 1; // after `>`
277            let name_end = slice[name_start..]
278                .find('}')
279                .map(|rel| name_start + rel)
280                .ok_or_else(|| ProsaicError::TemplateParseError {
281                    template: source.to_string(),
282                    position: start + i,
283                    reason: "unclosed `{>`".to_string(),
284                })?;
285
286            let name = slice[name_start..name_end].trim().to_string();
287            if name.is_empty() {
288                return Err(ProsaicError::TemplateParseError {
289                    template: source.to_string(),
290                    position: start + i,
291                    reason: "empty partial name".to_string(),
292                });
293            }
294
295            segments.push(Segment::Partial { name });
296            i = name_end + 1;
297            literal_start = i;
298            continue;
299        }
300
301        if is_conditional {
302            // Parse {?key}...{/?}
303            let key_start = content_start + 1; // after `?`
304            let key_end = slice[key_start..]
305                .find('}')
306                .map(|rel| key_start + rel)
307                .ok_or_else(|| ProsaicError::TemplateParseError {
308                    template: source.to_string(),
309                    position: start + i,
310                    reason: "unclosed `{?`".to_string(),
311                })?;
312
313            let condition_key = slice[key_start..key_end].trim().to_string();
314            if condition_key.is_empty() {
315                return Err(ProsaicError::TemplateParseError {
316                    template: source.to_string(),
317                    position: start + i,
318                    reason: "empty condition key".to_string(),
319                });
320            }
321
322            let inner_start = key_end + 1;
323            let inner_end = find_matching_close(slice, inner_start).ok_or_else(|| {
324                ProsaicError::TemplateParseError {
325                    template: source.to_string(),
326                    position: start + i,
327                    reason: format!("unclosed conditional `{{?{condition_key}}}`"),
328                }
329            })?;
330
331            let inner_segments = parse_segments(source, start + inner_start, start + inner_end)?;
332
333            segments.push(Segment::Conditional {
334                condition_key,
335                inner: inner_segments,
336            });
337
338            // Advance past `{/?}` (4 chars)
339            i = inner_end + 4;
340            literal_start = i;
341        } else {
342            // Regular slot — find matching `}` (with `{` nesting support)
343            let mut slot_end: Option<usize> = None;
344            let mut depth: i32 = 1;
345            let mut j = content_start;
346            while j < bytes.len() {
347                match bytes[j] {
348                    b'{' => depth += 1,
349                    b'}' => {
350                        depth -= 1;
351                        if depth == 0 {
352                            slot_end = Some(j);
353                            break;
354                        }
355                    }
356                    _ => {}
357                }
358                j += 1;
359            }
360            let slot_end = slot_end.ok_or_else(|| ProsaicError::TemplateParseError {
361                template: source.to_string(),
362                position: start + i,
363                reason: "unclosed `{`".to_string(),
364            })?;
365
366            let slot_content = &slice[content_start..slot_end];
367            let segment = parse_slot(slot_content, source, start + i)?;
368            segments.push(segment);
369
370            i = slot_end + 1;
371            literal_start = i;
372        }
373    }
374
375    // Flush trailing literal
376    if literal_start < slice.len() {
377        segments.push(Segment::Literal(slice[literal_start..].to_string()));
378    }
379
380    Ok(segments)
381}
382
383/// Find the position of the matching `{/?}` for a conditional opened at `start`.
384/// Handles nested conditionals.
385fn find_matching_close(slice: &str, start: usize) -> Option<usize> {
386    let mut depth: i32 = 1;
387    let bytes = slice.as_bytes();
388    let mut i = start;
389
390    while i < bytes.len() {
391        if i + 1 < bytes.len() && bytes[i] == b'{' {
392            if bytes[i + 1] == b'?' {
393                depth += 1;
394                i += 2;
395                continue;
396            }
397            if i + 2 < bytes.len() && bytes[i + 1] == b'/' && bytes[i + 2] == b'?' {
398                depth -= 1;
399                if depth == 0 {
400                    return Some(i);
401                }
402                i += 3;
403                continue;
404            }
405        }
406        i += 1;
407    }
408
409    None
410}
411
412fn parse_slot(content: &str, source: &str, position: usize) -> Result<Segment, ProsaicError> {
413    let parts: Vec<&str> = content.split('|').collect();
414
415    let key = parts[0].trim();
416    if key.is_empty() {
417        return Err(ProsaicError::TemplateParseError {
418            template: source.to_string(),
419            position,
420            reason: "empty slot key".to_string(),
421        });
422    }
423
424    let mut pipes = Vec::new();
425    for part in &parts[1..] {
426        let pipe = parse_pipe(part.trim(), source, position)?;
427        pipes.push(pipe);
428    }
429
430    Ok(Segment::Slot {
431        key: key.to_string(),
432        pipes,
433    })
434}
435
436fn parse_pipe(content: &str, source: &str, position: usize) -> Result<Pipe, ProsaicError> {
437    if content.is_empty() {
438        return Err(ProsaicError::TemplateParseError {
439            template: source.to_string(),
440            position,
441            reason: "empty pipe name".to_string(),
442        });
443    }
444
445    if let Some((name, arg_str)) = content.split_once(':') {
446        let name = name.trim();
447        let arg_str = arg_str.trim();
448
449        let arg = if let Ok(n) = arg_str.parse::<usize>() {
450            PipeArg::Number(n)
451        } else {
452            PipeArg::String(arg_str.to_string())
453        };
454
455        Ok(Pipe {
456            name: name.to_string(),
457            arg: Some(arg),
458        })
459    } else {
460        Ok(Pipe {
461            name: content.to_string(),
462            arg: None,
463        })
464    }
465}
466
467fn infer_segments(segments: &[Segment], out: &mut Vec<(String, ValueType)>) -> Result<(), String> {
468    for seg in segments {
469        match seg {
470            Segment::Literal(_) | Segment::Partial { .. } => {}
471            Segment::Slot { key, pipes } => {
472                let slot_ty = slot_type_from_pipes(key, pipes)?;
473                unify(out, key, slot_ty)?;
474            }
475            Segment::Conditional {
476                condition_key,
477                inner,
478            } => {
479                unify(out, condition_key, ValueType::Any)?;
480                infer_segments(inner, out)?;
481            }
482        }
483    }
484    Ok(())
485}
486
487fn slot_type_from_pipes(key: &str, pipes: &[Pipe]) -> Result<ValueType, String> {
488    // Bare slot: unconstrained.
489    let Some(first) = pipes.first() else {
490        return Ok(ValueType::Any);
491    };
492
493    let first_spec = lookup_spec(&first.name)?;
494    let slot_ty = first_spec.input;
495    let mut current_output = first_spec.output;
496    let mut prev_name: &str = &first.name;
497
498    for next in &pipes[1..] {
499        let next_spec = lookup_spec(&next.name)?;
500        if !types_compatible(current_output, next_spec.input) {
501            return Err(format!(
502                "pipe chain mismatch on slot `{key}`: \
503                 pipe `{prev_name}` outputs {current_output:?} but pipe `{cur}` expects {expected:?}",
504                cur = next.name,
505                expected = next_spec.input,
506            ));
507        }
508        current_output = next_spec.output;
509        prev_name = &next.name;
510    }
511
512    Ok(slot_ty)
513}
514
515fn lookup_spec(name: &str) -> Result<&'static PipeSpec, String> {
516    pipe_spec(name).ok_or_else(|| format!("unknown pipe `{name}`"))
517}
518
519fn unify(out: &mut Vec<(String, ValueType)>, key: &str, ty: ValueType) -> Result<(), String> {
520    if let Some(entry) = out.iter_mut().find(|(k, _)| k == key) {
521        entry.1 = match (entry.1, ty) {
522            (ValueType::Any, t) | (t, ValueType::Any) => t,
523            (a, b) if a == b => a,
524            (a, b) => {
525                return Err(format!(
526                    "slot `{key}` has conflicting types: used as both {a:?} and {b:?}"
527                ));
528            }
529        };
530    } else {
531        out.push((key.to_string(), ty));
532    }
533    Ok(())
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn parse_literal_only() {
542        let t = Template::parse("hello world").unwrap();
543        assert_eq!(
544            t.segments,
545            vec![Segment::Literal("hello world".to_string())]
546        );
547    }
548
549    #[test]
550    fn parse_single_slot() {
551        let t = Template::parse("{name}").unwrap();
552        assert_eq!(
553            t.segments,
554            vec![Segment::Slot {
555                key: "name".to_string(),
556                pipes: vec![],
557            }]
558        );
559    }
560
561    #[test]
562    fn parse_slot_with_surrounding_text() {
563        let t = Template::parse("Hello {name}!").unwrap();
564        assert_eq!(
565            t.segments,
566            vec![
567                Segment::Literal("Hello ".to_string()),
568                Segment::Slot {
569                    key: "name".to_string(),
570                    pipes: vec![],
571                },
572                Segment::Literal("!".to_string()),
573            ]
574        );
575    }
576
577    #[test]
578    fn parse_slot_with_pipe() {
579        let t = Template::parse("{name|capitalize}").unwrap();
580        assert_eq!(
581            t.segments,
582            vec![Segment::Slot {
583                key: "name".to_string(),
584                pipes: vec![Pipe {
585                    name: "capitalize".to_string(),
586                    arg: None,
587                }],
588            }]
589        );
590    }
591
592    #[test]
593    fn parse_slot_with_pipe_and_string_arg() {
594        let t = Template::parse("{count|pluralize:item}").unwrap();
595        assert_eq!(
596            t.segments,
597            vec![Segment::Slot {
598                key: "count".to_string(),
599                pipes: vec![Pipe {
600                    name: "pluralize".to_string(),
601                    arg: Some(PipeArg::String("item".to_string())),
602                }],
603            }]
604        );
605    }
606
607    #[test]
608    fn parse_slot_with_pipe_and_number_arg() {
609        let t = Template::parse("{items|truncate:3}").unwrap();
610        assert_eq!(
611            t.segments,
612            vec![Segment::Slot {
613                key: "items".to_string(),
614                pipes: vec![Pipe {
615                    name: "truncate".to_string(),
616                    arg: Some(PipeArg::Number(3)),
617                }],
618            }]
619        );
620    }
621
622    #[test]
623    fn parse_chained_pipes() {
624        let t = Template::parse("{items|truncate:3|join}").unwrap();
625        assert_eq!(
626            t.segments,
627            vec![Segment::Slot {
628                key: "items".to_string(),
629                pipes: vec![
630                    Pipe {
631                        name: "truncate".to_string(),
632                        arg: Some(PipeArg::Number(3)),
633                    },
634                    Pipe {
635                        name: "join".to_string(),
636                        arg: None,
637                    },
638                ],
639            }]
640        );
641    }
642
643    #[test]
644    fn parse_multiple_slots() {
645        let t = Template::parse("{a} and {b}").unwrap();
646        assert_eq!(
647            t.segments,
648            vec![
649                Segment::Slot {
650                    key: "a".to_string(),
651                    pipes: vec![],
652                },
653                Segment::Literal(" and ".to_string()),
654                Segment::Slot {
655                    key: "b".to_string(),
656                    pipes: vec![],
657                },
658            ]
659        );
660    }
661
662    #[test]
663    fn parse_unclosed_brace_is_error() {
664        let result = Template::parse("hello {name");
665        assert!(matches!(
666            result,
667            Err(ProsaicError::TemplateParseError { .. })
668        ));
669    }
670
671    #[test]
672    fn parse_empty_slot_is_error() {
673        let result = Template::parse("hello {}");
674        assert!(matches!(
675            result,
676            Err(ProsaicError::TemplateParseError { .. })
677        ));
678    }
679
680    #[test]
681    fn parse_empty_pipe_name_is_error() {
682        let result = Template::parse("{name|}");
683        assert!(matches!(
684            result,
685            Err(ProsaicError::TemplateParseError { .. })
686        ));
687    }
688
689    #[test]
690    fn parse_complex_template() {
691        let t = Template::parse(
692            "The {entity_type} {old_name} was renamed to {new_name} \
693             which impacts {count} direct {count|pluralize:consumer} \
694             [{consumers|truncate:3|join}]",
695        )
696        .unwrap();
697
698        // "The " {entity_type} " " {old_name} " was renamed to " {new_name}
699        // " which impacts " {count} " direct " {count|pluralize:consumer}
700        // " [" {consumers|truncate:3|join} "]"
701        assert_eq!(t.segments.len(), 13);
702    }
703
704    // ── Conditional tests ───────────────────────────────────────────────
705
706    #[test]
707    fn parse_conditional_section() {
708        let t = Template::parse("foo{?count} bar{/?} baz").unwrap();
709        assert_eq!(t.segments.len(), 3);
710        assert_eq!(t.segments[0], Segment::Literal("foo".into()));
711        assert!(matches!(t.segments[1], Segment::Conditional { .. }));
712        assert_eq!(t.segments[2], Segment::Literal(" baz".into()));
713    }
714
715    #[test]
716    fn parse_conditional_with_inner_slot() {
717        let t = Template::parse("{name}{?count}, {count} items{/?}").unwrap();
718        assert_eq!(t.segments.len(), 2);
719        if let Segment::Conditional {
720            condition_key,
721            inner,
722        } = &t.segments[1]
723        {
724            assert_eq!(condition_key, "count");
725            assert_eq!(inner.len(), 3); // ", ", {count}, " items"
726        } else {
727            panic!("Expected Conditional segment");
728        }
729    }
730
731    #[test]
732    fn parse_unclosed_conditional_is_error() {
733        let result = Template::parse("{?count} never closed");
734        assert!(matches!(
735            result,
736            Err(ProsaicError::TemplateParseError { .. })
737        ));
738    }
739
740    #[test]
741    fn parse_empty_conditional_key_is_error() {
742        let result = Template::parse("{?}content{/?}");
743        assert!(matches!(
744            result,
745            Err(ProsaicError::TemplateParseError { .. })
746        ));
747    }
748
749    // ── Partial tests ───────────────────────────────────────────────────
750
751    #[test]
752    fn parse_partial_reference() {
753        let t = Template::parse("start {>tail} end").unwrap();
754        assert_eq!(t.segments.len(), 3);
755        assert!(matches!(&t.segments[1], Segment::Partial { name } if name == "tail"));
756    }
757
758    #[test]
759    fn parse_empty_partial_name_is_error() {
760        let result = Template::parse("{>}");
761        assert!(matches!(
762            result,
763            Err(ProsaicError::TemplateParseError { .. })
764        ));
765    }
766
767    #[test]
768    fn parse_unclosed_partial_is_error() {
769        let result = Template::parse("{>tail");
770        assert!(matches!(
771            result,
772            Err(ProsaicError::TemplateParseError { .. })
773        ));
774    }
775
776    // ── literal_tokens tests ────────────────────────────────────────────
777
778    #[test]
779    fn literal_tokens_simple() {
780        let t = Template::parse("The {type} {name} was modified").unwrap();
781        let lits = t.literal_tokens();
782        assert_eq!(lits, vec!["The ", " ", " was modified"]);
783    }
784
785    #[test]
786    fn literal_tokens_from_conditional_sections() {
787        let t = Template::parse("{name}{?count}, impacting {count} consumers{/?}").unwrap();
788        let lits = t.literal_tokens();
789        assert!(lits.iter().any(|l| l.contains("impacting")));
790        assert!(lits.iter().any(|l| l.contains("consumers")));
791    }
792
793    #[test]
794    fn literal_tokens_empty_for_all_slots() {
795        let t = Template::parse("{a}{b}{c}").unwrap();
796        assert!(t.literal_tokens().is_empty());
797    }
798
799    #[test]
800    fn literal_tokens_skips_partial_nodes() {
801        // Partial nodes are opaque at parse time; their literals are only
802        // reachable after engine expansion.
803        let t = Template::parse("prefix {>partial_name} suffix").unwrap();
804        let lits = t.literal_tokens();
805        assert_eq!(lits, vec!["prefix ", " suffix"]);
806    }
807
808    #[test]
809    fn literal_tokens_nested_conditional_recursion() {
810        // Conditional inside conditional should surface literals at all depths.
811        let t = Template::parse("{?a}outer{?b} inner{/?}{/?}").unwrap();
812        let lits = t.literal_tokens();
813        assert!(lits.iter().any(|l| l.contains("outer")));
814        assert!(lits.iter().any(|l| l.contains("inner")));
815    }
816
817    // ── slot_keys tests ─────────────────────────────────────────────────
818
819    #[test]
820    fn slot_keys_simple() {
821        let t = Template::parse("{a} and {b}").unwrap();
822        let mut keys = t.slot_keys();
823        keys.sort();
824        assert_eq!(keys, vec!["a", "b"]);
825    }
826
827    #[test]
828    fn slot_keys_includes_condition_key() {
829        let t = Template::parse("{name}{?count}, {count} items{/?}").unwrap();
830        let keys = t.slot_keys();
831        assert!(keys.contains(&"name".to_string()));
832        // count appears as condition key and as inner slot key
833        assert!(keys.iter().filter(|k| k.as_str() == "count").count() >= 2);
834    }
835
836    #[test]
837    fn slot_keys_skips_partials() {
838        let t = Template::parse("start {>partial_name} {slot} end").unwrap();
839        let keys = t.slot_keys();
840        assert_eq!(keys, vec!["slot"]);
841        assert!(!keys.contains(&"partial_name".to_string()));
842    }
843
844    #[test]
845    fn slot_keys_empty_for_literal_only() {
846        let t = Template::parse("just a string").unwrap();
847        assert!(t.slot_keys().is_empty());
848    }
849
850    #[test]
851    fn slot_keys_nested_conditional() {
852        let t = Template::parse("{?a}outer{?b} inner{/?}{/?}").unwrap();
853        let mut keys = t.slot_keys();
854        keys.sort();
855        keys.dedup();
856        assert_eq!(keys, vec!["a", "b"]);
857    }
858
859    // ── pipe_names tests ─────────────────────────────────────────────────
860
861    #[test]
862    fn pipe_names_simple() {
863        let t = Template::parse("{count|pluralize:item}").unwrap();
864        assert_eq!(t.pipe_names(), vec!["pluralize"]);
865    }
866
867    #[test]
868    fn pipe_names_chained() {
869        let t = Template::parse("{items|truncate:3|join}").unwrap();
870        assert_eq!(t.pipe_names(), vec!["truncate", "join"]);
871    }
872
873    #[test]
874    fn pipe_names_empty_when_no_pipes() {
875        let t = Template::parse("{name} and {other}").unwrap();
876        assert!(t.pipe_names().is_empty());
877    }
878
879    #[test]
880    fn pipe_names_inside_conditional() {
881        let t = Template::parse("{?count}{count|pluralize:item}{/?}").unwrap();
882        assert_eq!(t.pipe_names(), vec!["pluralize"]);
883    }
884
885    #[test]
886    fn pipe_names_arg_not_included_in_name() {
887        // pipe name is "truncate", not "truncate:3"
888        let t = Template::parse("{items|truncate:3}").unwrap();
889        let names = t.pipe_names();
890        assert_eq!(names, vec!["truncate"]);
891        assert!(!names.iter().any(|n| n.contains(':')));
892    }
893
894    // ── infer_types tests ───────────────────────────────────────────────
895
896    use prosaic_common::ValueType;
897
898    fn types(t: &Template) -> Vec<(String, ValueType)> {
899        let mut v = t.infer_types().expect("expected successful inference");
900        v.sort_by(|a, b| a.0.cmp(&b.0));
901        v
902    }
903
904    #[test]
905    fn infer_bare_slot_is_any() {
906        let t = Template::parse("{x}").unwrap();
907        assert_eq!(types(&t), vec![("x".into(), ValueType::Any)]);
908    }
909
910    #[test]
911    fn infer_slot_with_number_pipe_is_number() {
912        let t = Template::parse("{count|pluralize:item}").unwrap();
913        assert_eq!(types(&t), vec![("count".into(), ValueType::Number)]);
914    }
915
916    #[test]
917    fn infer_slot_input_is_first_pipe_input_for_list_chain() {
918        // The chain `truncate|join` starts by consuming a List and ends
919        // producing a String. `infer_types` returns the slot's required
920        // INPUT type (List), not the chain's final output.
921        let t = Template::parse("{items|truncate:3|join}").unwrap();
922        assert_eq!(types(&t), vec![("items".into(), ValueType::List)]);
923    }
924
925    #[test]
926    fn infer_chain_mismatch_is_error() {
927        let t = Template::parse("{x|capitalize|pluralize}").unwrap();
928        let err = t.infer_types().unwrap_err();
929        // Error must name the pipes AND the types involved so downstream
930        // callers (macro / register_template) don't need to re-derive them.
931        assert!(err.contains("capitalize"), "error was: {err}");
932        assert!(err.contains("pluralize"), "error was: {err}");
933        assert!(
934            err.contains("String"),
935            "error should name the output type; got: {err}"
936        );
937        assert!(
938            err.contains("Number"),
939            "error should name the expected input; got: {err}"
940        );
941    }
942
943    #[test]
944    fn infer_multi_mention_any_and_number_unifies_to_number() {
945        let t = Template::parse("{x|pluralize:item} {x}").unwrap();
946        assert_eq!(types(&t), vec![("x".into(), ValueType::Number)]);
947    }
948
949    #[test]
950    fn infer_multi_mention_conflict_is_error() {
951        let t = Template::parse("{x|pluralize:item} {x|join}").unwrap();
952        let err = t.infer_types().unwrap_err();
953        // The implementation uses backtick quoting — assert it directly so
954        // a future style change surfaces here instead of silently passing.
955        assert!(err.contains("`x`"), "error was: {err}");
956        assert!(err.contains("Number"), "error was: {err}");
957        assert!(err.contains("List"), "error was: {err}");
958    }
959
960    #[test]
961    fn infer_same_bare_slot_twice_stays_any() {
962        // Two bare mentions of the same slot unify as Any ∩ Any → Any,
963        // not an error.
964        let t = Template::parse("{x} and {x}").unwrap();
965        assert_eq!(types(&t), vec![("x".into(), ValueType::Any)]);
966    }
967
968    #[test]
969    fn infer_unknown_pipe_is_error() {
970        let t = Template::parse("{x|nonexistent_pipe}").unwrap();
971        let err = t.infer_types().unwrap_err();
972        assert!(err.contains("nonexistent_pipe"), "error was: {err}");
973    }
974
975    #[test]
976    fn infer_conditional_guard_slot_is_any() {
977        let t = Template::parse("{?count}hello{/?}").unwrap();
978        let ts = types(&t);
979        assert_eq!(ts, vec![("count".into(), ValueType::Any)]);
980    }
981
982    #[test]
983    fn infer_pipes_inside_conditional_are_checked() {
984        let t = Template::parse("{?count}{count|pluralize:item}{/?}").unwrap();
985        assert_eq!(types(&t), vec![("count".into(), ValueType::Number)]);
986    }
987
988    #[test]
989    fn infer_literal_only_is_empty() {
990        let t = Template::parse("no slots").unwrap();
991        assert_eq!(types(&t), vec![]);
992    }
993
994    #[test]
995    fn infer_skips_partial_nodes() {
996        // Partial nodes are opaque at parse time — any slots they
997        // reference are resolved at registration time, not here.
998        let t = Template::parse("{x|pluralize:item} {>some_partial}").unwrap();
999        assert_eq!(types(&t), vec![("x".into(), ValueType::Number)]);
1000    }
1001
1002    // ── as_bare_slots tests ──────────────────────────────────────────────
1003
1004    #[test]
1005    fn as_bare_slots_accepts_bare_template() {
1006        let t = Template::parse("Hello {name} world").unwrap();
1007        let segs = t.as_bare_slots().unwrap();
1008        // 3 segments: "Hello ", slot name, " world"
1009        assert_eq!(segs.len(), 3);
1010        assert_eq!(segs[0], BareSegment::Text("Hello "));
1011        assert_eq!(segs[1], BareSegment::Slot("name"));
1012        assert_eq!(segs[2], BareSegment::Text(" world"));
1013    }
1014
1015    #[test]
1016    fn as_bare_slots_accepts_literal_only_template() {
1017        let t = Template::parse("no slots here").unwrap();
1018        let segs = t.as_bare_slots().unwrap();
1019        assert_eq!(segs.len(), 1);
1020        assert_eq!(segs[0], BareSegment::Text("no slots here"));
1021    }
1022
1023    #[test]
1024    fn as_bare_slots_accepts_multiple_bare_slots() {
1025        let t = Template::parse("{greeting}, {name}!").unwrap();
1026        let segs = t.as_bare_slots().unwrap();
1027        // 4 segments: slot "greeting", ", ", slot "name", "!"
1028        assert_eq!(segs.len(), 4);
1029        assert_eq!(segs[0], BareSegment::Slot("greeting"));
1030        assert_eq!(segs[1], BareSegment::Text(", "));
1031        assert_eq!(segs[2], BareSegment::Slot("name"));
1032        assert_eq!(segs[3], BareSegment::Text("!"));
1033    }
1034
1035    #[test]
1036    fn as_bare_slots_rejects_piped_template() {
1037        let t = Template::parse("Hello {name|capitalize}").unwrap();
1038        assert!(t.as_bare_slots().is_none());
1039    }
1040
1041    #[test]
1042    fn as_bare_slots_rejects_conditional_template() {
1043        let t = Template::parse("Hello{?greet} friend{/?}").unwrap();
1044        assert!(t.as_bare_slots().is_none());
1045    }
1046
1047    #[test]
1048    fn as_bare_slots_rejects_partial_template() {
1049        let t = Template::parse("prefix {>partial_name} suffix").unwrap();
1050        assert!(t.as_bare_slots().is_none());
1051    }
1052
1053    #[test]
1054    fn as_bare_slots_rejects_chained_pipes() {
1055        let t = Template::parse("{items|truncate:3|join}").unwrap();
1056        assert!(t.as_bare_slots().is_none());
1057    }
1058}