Skip to main content

nika_engine/binding/
template.rs

1//! Template Resolution — 3-Pass Variable Substitution
2//!
3//! This module resolves template variables in workflow strings using a 3-pass
4//! architecture that ensures security isolation between different binding sources.
5//!
6//! # 3-Pass Resolution Architecture
7//!
8//! Template resolution happens in strict sequential order:
9//!
10//! ```text
11//! ┌─────────────────────────────────────────────────────────────────────────┐
12//! │  3-PASS TEMPLATE RESOLUTION                                    │
13//! ├─────────────────────────────────────────────────────────────────────────┤
14//! │                                                                         │
15//! │  Pass 1: {{with.alias}} — Task output bindings                         │
16//! │  ─────────────────────────────────────────────────────────────────────  │
17//! │  • Resolves task outputs bound via `with:` block                        │
18//! │  • Supports nested paths: {{with.data.field}}                           │
19//! │  • Supports array indexing: {{with.items[0]}} or {{with.items.0}}       │
20//! │  • Supports |shell modifier: {{with.value|shell}}                       │
21//! │  • Lazy bindings resolved on-demand via RunContext                       │
22//! │                                                                         │
23//! │  Pass 2: {{context.*}} — Workflow context files                         │
24//! │  ─────────────────────────────────────────────────────────────────────  │
25//! │  • {{context.files.alias}} — Loaded from `context.files` block          │
26//! │  • {{context.session.key}} — Loaded from `context.session` file         │
27//! │  • Content is loaded at workflow start, before task execution           │
28//! │                                                                         │
29//! │  Pass 3: {{inputs.param}} — Workflow input parameters                   │
30//! │  ─────────────────────────────────────────────────────────────────────  │
31//! │  • Resolves from workflow `inputs:` definitions                         │
32//! │  • Uses `default` values when not provided at runtime                   │
33//! │  • Supports nested paths: {{inputs.config.theme}}                       │
34//! │                                                                         │
35//! └─────────────────────────────────────────────────────────────────────────┘
36//! ```
37//!
38//! # Security Isolation
39//!
40//! **CRITICAL**: Each pass operates on the OUTPUT of the previous pass, but
41//! template markers in VALUES are NOT re-evaluated. This prevents injection:
42//!
43//! ```yaml
44//! # If {{with.user_input}} resolves to "{{context.files.secret}}"
45//! # The output is literally "{{context.files.secret}}", NOT the file content
46//! ```
47//!
48//! See the `injection_*` tests for comprehensive security verification.
49//!
50//! # Syntax Reference
51//!
52//! | Pattern | Source | Example |
53//! |---------|--------|---------|
54//! | `{{with.alias}}` | Task `with:` block | `{{with.forecast}}` |
55//! | `{{with.alias.field}}` | Nested JSON access | `{{with.data.name}}` |
56//! | `{{with.alias[N]}}` | Array indexing | `{{with.items[0]}}` |
57//! | `{{with.alias\|shell}}` | Shell-escaped | `{{with.filename\|shell}}` |
58//! | `{{context.files.X}}` | Context file | `{{context.files.brand}}` |
59//! | `{{context.session.X}}` | Session data | `{{context.session.focus}}` |
60//! | `{{inputs.param}}` | Input parameter | `{{inputs.topic}}` |
61//!
62//! # Performance
63//!
64//! - Returns `Cow::Borrowed` when no templates (zero allocation)
65//! - Zero-clone traversal (references until final value)
66//! - SmallVec for error collection (stack-allocated up to 4)
67//! - Pre-compiled regex via `LazyLock`
68
69use std::borrow::Cow;
70use std::sync::LazyLock;
71
72use regex::Regex;
73use rustc_hash::{FxHashMap, FxHashSet};
74use serde_json::Value;
75use smallvec::SmallVec;
76
77use crate::error::NikaError;
78use crate::store::RunContext;
79
80use super::resolve::ResolvedBindings;
81use super::transform::TransformExpr;
82
83/// Maximum number of template variables allowed per string.
84///
85/// Prevents CPU exhaustion from pathological templates containing thousands of
86/// `{{...}}` blocks that trigger regex backtracking and allocation storms.
87const MAX_TEMPLATE_VARS: usize = 256;
88
89/// Maximum path depth for nested alias traversal (e.g., `a.b.c.d.e`).
90///
91/// Prevents stack/allocation exhaustion from malicious deep paths like
92/// `{{a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z}}`.
93const MAX_PATH_DEPTH: usize = 32;
94
95/// Pre-compiled regex for {{with.alias}} pattern.
96/// Supports optional pipe transforms: {{with.alias|shell}}, {{with.alias|uppercase|trim}}
97/// Also supports bracket notation after preprocessing: {{with.items[0]}} → {{with.items.0}}
98static USE_RE: LazyLock<Regex> = LazyLock::new(|| {
99    Regex::new(r"\{\{\s*with\.(\w+(?:\.\w+)*)(\s*(?:\|\s*\w+)+)?\s*\}\}").unwrap()
100});
101
102/// Pre-compiled regex for bracket array notation
103/// Converts [0] to .0 for uniform handling
104static BRACKET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[(\d+)\]").unwrap());
105
106// ═══════════════════════════════════════════════════════════════════════════════
107// New 2-pass template engine with iterative parser
108// ═══════════════════════════════════════════════════════════════════════════════
109
110/// Matches ANY {{...}} block. Content is parsed by parse_template_expr().
111/// Unified regex that replaces per-namespace patterns -- dispatched via parse_template_expr().
112static TEMPLATE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{(.*?)\}\}").unwrap());
113
114/// Parsed template expression from inside `{{ ... }}`
115///
116/// Uses an iterative parser that correctly handles words like "contextual"
117/// and enables arbitrary transform chains.
118#[derive(Debug, Clone, PartialEq)]
119pub enum TemplateExpr {
120    /// Alias from `with:` block, with optional transforms
121    /// e.g., `"title"`, `"title | upper | trim"`, `"data.items[0]"`
122    Alias {
123        path: String,
124        transforms: Vec<String>,
125    },
126    /// Direct context reference: `"context.files.brand"` or `"context.session.key"`
127    Context {
128        path: String,
129        transforms: Vec<String>,
130    },
131    /// Direct input reference: `"inputs.locale"` or `"inputs.config.theme"`
132    Input {
133        path: String,
134        transforms: Vec<String>,
135    },
136}
137
138/// Parse the content inside `{{ ... }}` into a TemplateExpr.
139///
140/// Grammar:
141/// ```text
142///   expr := "context." path             → Context
143///         | "inputs." path              → Input
144///         | "with." alias_path ("|" t)* → Alias (with. prefix stripped)
145///         | alias_path ("|" transform)* → Alias
146/// ```
147///
148/// This replaces the buggy negative-lookahead regex approach:
149/// 1. No negative lookahead bugs — exact `strip_prefix("context.")` is unambiguous
150/// 2. Arbitrary transform chains — `split('|')` handles any number of pipes
151/// 3. Better error messages — parser can report exactly what's wrong
152/// 4. Simpler to maintain — no complex regex to debug
153pub fn parse_template_expr(content: &str) -> Result<TemplateExpr, NikaError> {
154    let trimmed = content.trim();
155
156    if trimmed.is_empty() {
157        return Err(NikaError::TemplateParse {
158            position: 0,
159            details: format!("Empty template expression in '{}'", content),
160        });
161    }
162
163    // Check for context.* and inputs.* FIRST (exact prefix match)
164    // "contextual" → NOT Context (no dot after "context")
165    // "inputstream" → NOT Input (no dot after "inputs")
166    if let Some(rest) = trimmed.strip_prefix("context.") {
167        if rest.is_empty() {
168            return Err(NikaError::TemplateParse {
169                position: 0,
170                details: format!("Empty context path after 'context.' in '{}'", content),
171            });
172        }
173        let parts: Vec<&str> = rest.split('|').map(str::trim).collect();
174        let path = parts[0].to_string();
175        let transforms: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
176        if path.is_empty() {
177            return Err(NikaError::TemplateParse {
178                position: 0,
179                details: format!("Empty context path after 'context.' in '{}'", content),
180            });
181        }
182        return Ok(TemplateExpr::Context { path, transforms });
183    }
184    if let Some(rest) = trimmed.strip_prefix("inputs.") {
185        if rest.is_empty() {
186            return Err(NikaError::TemplateParse {
187                position: 0,
188                details: format!("Empty input path after 'inputs.' in '{}'", content),
189            });
190        }
191        let parts: Vec<&str> = rest.split('|').map(str::trim).collect();
192        let path = parts[0].to_string();
193        let transforms: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
194        if path.is_empty() {
195            return Err(NikaError::TemplateParse {
196                position: 0,
197                details: format!("Empty input path after 'inputs.' in '{}'", content),
198            });
199        }
200        return Ok(TemplateExpr::Input { path, transforms });
201    }
202
203    // Strip "with." prefix to get alias path
204    // "with.data" → alias path "data"
205    let effective = trimmed.strip_prefix("with.").unwrap_or(trimmed);
206
207    // Everything else is an alias (possibly with transforms)
208    // Split by | to get alias path and transforms
209    let parts: Vec<&str> = effective.split('|').map(str::trim).collect();
210    let path = parts[0].to_string();
211    let transforms: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
212
213    if path.is_empty() {
214        return Err(NikaError::TemplateParse {
215            position: 0,
216            details: format!("Empty alias path in '{}'", content),
217        });
218    }
219
220    Ok(TemplateExpr::Alias { path, transforms })
221}
222
223/// Convert a Value to its display string for template interpolation
224///
225/// Unlike `value_to_string()` which errors on null, this returns empty string.
226///
227/// - String: raw string (no quotes)
228/// - Number/Bool: to_string()
229/// - Array/Object: JSON-serialize (lossless for LLMs)
230/// - Null: empty string
231fn value_to_display(value: &Value) -> Cow<'_, str> {
232    match value {
233        Value::String(s) => Cow::Borrowed(s.as_str()),
234        Value::Null => Cow::Borrowed(""),
235        Value::Bool(b) => Cow::Owned(b.to_string()),
236        Value::Number(n) => Cow::Owned(n.to_string()),
237        other => Cow::Owned(other.to_string()), // JSON representation for objects/arrays
238    }
239}
240
241/// Resolve a dot-separated path against a FxHashMap of alias → Value
242///
243/// Supports nested paths like "data.items.0" or "data.users.0.name".
244fn resolve_alias_path(
245    path: &str,
246    with_values: &FxHashMap<String, Value>,
247) -> Result<Value, NikaError> {
248    // Guard against pathologically deep paths
249    let segment_count = path.split('.').count();
250    if segment_count > MAX_PATH_DEPTH {
251        return Err(NikaError::TemplateError {
252            template: path.to_string(),
253            reason: format!(
254                "Path depth {} exceeds maximum of {} segments",
255                segment_count, MAX_PATH_DEPTH
256            ),
257        });
258    }
259
260    let mut segments = path.split('.');
261    let alias = segments.next().ok_or_else(|| NikaError::TemplateError {
262        template: path.to_string(),
263        reason: "Empty alias path (no segments)".to_string(),
264    })?;
265
266    let base = with_values
267        .get(alias)
268        .ok_or_else(|| NikaError::TemplateError {
269            template: alias.to_string(),
270            reason: format!(
271                "Alias '{}' not found in 'with:' block. Available: [{}]",
272                alias,
273                with_values.keys().cloned().collect::<Vec<_>>().join(", ")
274            ),
275        })?;
276
277    // Auto-parse JSON strings so invoke/exec outputs stored as
278    // Value::String('{"hash":"blake3:..."}') can be traversed with
279    // nested paths like {{chart_result.hash}}.
280    // Matches navigate_segments() in binding/resolve.rs (NIKA-253 fix).
281    let effective_base =
282        crate::binding::jsonpath::try_parse_json_str(base).unwrap_or_else(|| base.clone());
283    let mut current = &effective_base;
284    let mut traversed: SmallVec<[&str; 8]> = SmallVec::new();
285    traversed.push(alias);
286
287    for segment in segments {
288        let next = if let Ok(idx) = segment.parse::<usize>() {
289            current.get(idx)
290        } else {
291            current.get(segment)
292        };
293
294        match next {
295            Some(v) => {
296                traversed.push(segment);
297                current = v;
298            }
299            None => {
300                if matches!(current, Value::Object(_) | Value::Array(_)) {
301                    let traversed_path = traversed.join(".");
302                    return Err(NikaError::PathNotFound {
303                        path: format!("{}.{}", traversed_path, segment),
304                    });
305                } else {
306                    let value_type = match current {
307                        Value::Null => "null",
308                        Value::Bool(_) => "bool",
309                        Value::Number(_) => "number",
310                        Value::String(_) => "string",
311                        _ => unreachable!(),
312                    };
313                    return Err(NikaError::InvalidTraversal {
314                        segment: segment.to_string(),
315                        value_type: value_type.to_string(),
316                        full_path: path.to_string(),
317                    });
318                }
319            }
320        }
321    }
322
323    Ok(current.clone())
324}
325
326/// Resolve all template references in a string
327///
328/// Pass 1: `{{alias}}` and `{{alias | transform}}` — resolved from `with:` values
329/// Pass 2: `{{context.*}}` and `{{inputs.*}}` — direct access (convenience)
330///
331/// Security: Pass 2 values are NOT re-evaluated by Pass 1 patterns.
332/// Template markers in resolved VALUES are never re-processed.
333///
334/// Features:
335/// - Takes `with_values` directly (not `ResolvedBindings`)
336/// - No `with.` prefix needed (`{{title}}` instead of `{{with.title}}`)
337/// - Supports arbitrary transform chains (`{{title | upper | trim}}`)
338/// - Returns empty string for null values (not error)
339pub fn resolve_with<'a>(
340    template: &'a str,
341    with_values: &FxHashMap<String, Value>,
342    datastore: &RunContext,
343) -> Result<Cow<'a, str>, NikaError> {
344    // Early return with borrowed string (zero alloc)
345    if !template.contains("{{") {
346        return Ok(Cow::Borrowed(template));
347    }
348
349    // Guard: reject templates with too many variable references
350    let var_count = template.matches("{{").count();
351    if var_count > MAX_TEMPLATE_VARS {
352        return Err(NikaError::TemplateError {
353            template: format!("(template with {} variables)", var_count),
354            reason: format!(
355                "Template contains {} variable references, exceeding the maximum of {}",
356                var_count, MAX_TEMPLATE_VARS
357            ),
358        });
359    }
360
361    // Normalize bracket notation to dot notation
362    // {{items[0]}} → {{items.0}}
363    let normalized = normalize_bracket_notation(template);
364    let template_str: &str = normalized.as_ref();
365
366    // ─── Pass 1: Find all {{...}} blocks, parse each, resolve aliases ───
367    let mut result = String::with_capacity(template_str.len() + 64);
368    let mut last_end = 0;
369    let mut errors: SmallVec<[String; 4]> = SmallVec::new();
370
371    for cap in TEMPLATE_RE.captures_iter(template_str) {
372        let m = cap.get(0).unwrap();
373        let content = &cap[1];
374
375        // Copy segment before this match
376        result.push_str(&template_str[last_end..m.start()]);
377
378        match parse_template_expr(content) {
379            Ok(TemplateExpr::Alias {
380                ref path,
381                ref transforms,
382            }) => {
383                match resolve_alias_path(path, with_values) {
384                    Ok(value) => {
385                        let has_shell = transforms.iter().any(|t| t == "shell");
386
387                        // When shell is in transforms, skip the full-chain application
388                        // to avoid double-processing. Only run the non-shell transforms
389                        // followed by escape_for_shell.
390                        // PERF(M5): Avoid value.clone() when no transforms —
391                        // value_to_display() takes &Value, so pass reference directly.
392                        let display = if has_shell {
393                            let non_shell: Vec<String> = transforms
394                                .iter()
395                                .filter(|t| *t != "shell")
396                                .cloned()
397                                .collect();
398                            if non_shell.is_empty() {
399                                escape_for_shell(&value_to_display(&value))
400                            } else {
401                                let transform_str = non_shell.join(" | ");
402                                let expr = TransformExpr::parse(&transform_str).map_err(|e| {
403                                    NikaError::TemplateParse {
404                                        position: m.start(),
405                                        details: format!(
406                                            "Transform parse error in '{{{{{}}}}}': {}",
407                                            content, e
408                                        ),
409                                    }
410                                })?;
411                                let transformed =
412                                    expr.apply(&value).map_err(|e| NikaError::TemplateParse {
413                                        position: m.start(),
414                                        details: format!(
415                                            "Transform apply error in '{{{{{}}}}}': {}",
416                                            content, e
417                                        ),
418                                    })?;
419                                escape_for_shell(&value_to_display(&transformed))
420                            }
421                        } else if transforms.is_empty() {
422                            // No transforms at all — direct reference, zero clone
423                            if is_in_json_context(template_str, m.start()) {
424                                escape_for_json(&value_to_display(&value)).into_owned()
425                            } else {
426                                value_to_display(&value).into_owned()
427                            }
428                        } else {
429                            // Apply transform chain (no shell)
430                            let transform_str = transforms.join(" | ");
431                            let expr = TransformExpr::parse(&transform_str).map_err(|e| {
432                                NikaError::TemplateParse {
433                                    position: m.start(),
434                                    details: format!(
435                                        "Transform parse error in '{{{{{}}}}}': {}",
436                                        content, e
437                                    ),
438                                }
439                            })?;
440                            let transformed =
441                                expr.apply(&value).map_err(|e| NikaError::TemplateParse {
442                                    position: m.start(),
443                                    details: format!(
444                                        "Transform apply error in '{{{{{}}}}}': {}",
445                                        content, e
446                                    ),
447                                })?;
448                            if is_in_json_context(template_str, m.start()) {
449                                escape_for_json(&value_to_display(&transformed)).into_owned()
450                            } else {
451                                value_to_display(&transformed).into_owned()
452                            }
453                        };
454                        result.push_str(&display);
455                    }
456                    Err(e) => {
457                        // Propagate structural errors (depth, parse) immediately;
458                        // only collect "not found" errors for the batch message.
459                        let msg = format!("{}", e);
460                        if msg.contains("exceeds maximum") || msg.contains("Empty alias path") {
461                            return Err(e);
462                        }
463                        errors.push(path.clone());
464                    }
465                }
466            }
467            Ok(TemplateExpr::Context { .. } | TemplateExpr::Input { .. }) => {
468                // Leave context/inputs refs for Pass 2 — re-emit as {{...}}
469                result.push_str(&format!("{{{{{}}}}}", content.trim()));
470            }
471            Err(_) => {
472                // Malformed expression — re-emit literally
473                result.push_str(m.as_str());
474            }
475        }
476
477        last_end = m.end();
478    }
479
480    if !errors.is_empty() {
481        return Err(NikaError::TemplateError {
482            template: errors.join(", "),
483            reason: "Alias(es) not resolved. Did you declare them in 'with:'?".to_string(),
484        });
485    }
486
487    // Copy remaining segment after last match
488    result.push_str(&template_str[last_end..]);
489
490    // ─── Pass 2: Resolve {{context.*}} and {{inputs.*}} (direct refs) ───
491    // SECURITY: Check the ORIGINAL template for context/inputs references, not
492    // the post-Pass-1 result. This prevents template injection where a with: value
493    // containing "{{context.files.x}}" triggers Pass 2 resolution.
494    let has_context = template.contains("context.");
495    let has_inputs = template.contains("inputs.");
496
497    if !has_context && !has_inputs {
498        return Ok(Cow::Owned(result));
499    }
500
501    if has_context && result.contains("{{") {
502        let intermediate = std::mem::take(&mut result);
503        result = String::with_capacity(intermediate.len() + 64);
504        let mut last_end = 0;
505        let mut context_errors: SmallVec<[String; 4]> = SmallVec::new();
506
507        for cap in TEMPLATE_RE.captures_iter(&intermediate) {
508            let m = cap.get(0).unwrap();
509            let inner = cap[1].trim();
510            let (path, transforms) = match parse_template_expr(inner) {
511                Ok(TemplateExpr::Context { path, transforms }) => (path, transforms),
512                _ => continue,
513            };
514            result.push_str(&intermediate[last_end..m.start()]);
515            let full_path = format!("context.{}", path);
516            match datastore.resolve_context_path(&full_path) {
517                Some(value) => {
518                    let replacement = if !transforms.is_empty() {
519                        let transform_str = transforms.join(" | ");
520                        let expr = TransformExpr::parse(&transform_str).map_err(|e| {
521                            NikaError::TemplateParse {
522                                position: m.start(),
523                                details: format!("Transform parse error: {}", e),
524                            }
525                        })?;
526                        let transformed =
527                            expr.apply(&value).map_err(|e| NikaError::TemplateParse {
528                                position: m.start(),
529                                details: format!("Transform apply error: {}", e),
530                            })?;
531                        if is_in_json_context(&intermediate, m.start()) {
532                            escape_for_json(&value_to_display(&transformed)).into_owned()
533                        } else {
534                            value_to_display(&transformed).into_owned()
535                        }
536                    } else {
537                        let s = context_value_to_string(&value, &full_path)?;
538                        if is_in_json_context(&intermediate, m.start()) {
539                            escape_for_json(&s).into_owned()
540                        } else {
541                            s.into_owned()
542                        }
543                    };
544                    result.push_str(&replacement);
545                }
546                None => {
547                    context_errors.push(full_path);
548                }
549            }
550
551            last_end = m.end();
552        }
553
554        if !context_errors.is_empty() {
555            return Err(NikaError::TemplateError {
556                template: context_errors.join(", "),
557                reason: "Context binding(s) not resolved. Check your 'context:' block in workflow."
558                    .to_string(),
559            });
560        }
561
562        result.push_str(&intermediate[last_end..]);
563    }
564
565    if has_inputs && result.contains("{{") {
566        let intermediate = std::mem::take(&mut result);
567        result = String::with_capacity(intermediate.len() + 64);
568        let mut last_end = 0;
569        let mut input_errors: SmallVec<[String; 4]> = SmallVec::new();
570
571        for cap in TEMPLATE_RE.captures_iter(&intermediate) {
572            let m = cap.get(0).unwrap();
573            let inner = cap[1].trim();
574            let (path, transforms) = match parse_template_expr(inner) {
575                Ok(TemplateExpr::Input { path, transforms }) => (path, transforms),
576                _ => continue,
577            };
578            result.push_str(&intermediate[last_end..m.start()]);
579            let full_path = format!("inputs.{}", path);
580            match datastore.resolve_input_path(&full_path) {
581                Some(value) => {
582                    let replacement = if !transforms.is_empty() {
583                        let transform_str = transforms.join(" | ");
584                        let expr = TransformExpr::parse(&transform_str).map_err(|e| {
585                            NikaError::TemplateParse {
586                                position: m.start(),
587                                details: format!("Transform parse error: {}", e),
588                            }
589                        })?;
590                        let transformed =
591                            expr.apply(&value).map_err(|e| NikaError::TemplateParse {
592                                position: m.start(),
593                                details: format!("Transform apply error: {}", e),
594                            })?;
595                        if is_in_json_context(&intermediate, m.start()) {
596                            escape_for_json(&value_to_display(&transformed)).into_owned()
597                        } else {
598                            value_to_display(&transformed).into_owned()
599                        }
600                    } else {
601                        let s = input_value_to_string(&value, &full_path)?;
602                        if is_in_json_context(&intermediate, m.start()) {
603                            escape_for_json(&s).into_owned()
604                        } else {
605                            s.into_owned()
606                        }
607                    };
608                    result.push_str(&replacement);
609                }
610                None => {
611                    input_errors.push(full_path);
612                }
613            }
614
615            last_end = m.end();
616        }
617
618        if !input_errors.is_empty() {
619            return Err(NikaError::TemplateError {
620                template: input_errors.join(", "),
621                reason: "Input binding(s) not resolved. Check your 'inputs:' block in workflow or provide defaults.".to_string(),
622            });
623        }
624
625        result.push_str(&intermediate[last_end..]);
626    }
627
628    Ok(Cow::Owned(result))
629}
630
631/// Extract all alias references from a template
632///
633/// Returns aliases used in `{{alias}}` patterns (no `use.` prefix).
634/// Does NOT return context/input refs — those are direct access.
635pub fn extract_with_refs(template: &str) -> Vec<String> {
636    if !template.contains("{{") {
637        return Vec::new();
638    }
639    let mut aliases = Vec::new();
640    for cap in TEMPLATE_RE.captures_iter(template) {
641        let content = &cap[1];
642        if let Ok(TemplateExpr::Alias { path, .. }) = parse_template_expr(content) {
643            let alias = path.split('.').next().unwrap().to_string();
644            aliases.push(alias);
645        }
646    }
647    aliases
648}
649
650/// Validate that all template alias references exist in declared aliases
651pub fn validate_with_refs(
652    template: &str,
653    declared_aliases: &FxHashSet<String>,
654    task_id: &str,
655) -> Result<(), NikaError> {
656    for alias in extract_with_refs(template) {
657        if !declared_aliases.contains(&alias) {
658            return Err(NikaError::UnknownAlias {
659                alias,
660                task_id: task_id.to_string(),
661            });
662        }
663    }
664    Ok(())
665}
666
667// ═══════════════════════════════════════════════════════════════════════════════
668// with: template engine (runtime Workflow path)
669// ═══════════════════════════════════════════════════════════════════════════════
670
671/// Escape for JSON string context
672///
673/// Returns `Cow::Borrowed` when no escaping is needed (common case for simple strings).
674fn escape_for_json(s: &str) -> Cow<'_, str> {
675    // Fast path: check if any escaping is needed
676    let needs_escape = s
677        .chars()
678        .any(|c| matches!(c, '"' | '\\' | '\n' | '\r' | '\t') || c.is_control());
679    if !needs_escape {
680        return Cow::Borrowed(s);
681    }
682
683    let mut result = String::with_capacity(s.len());
684    for ch in s.chars() {
685        match ch {
686            '"' => result.push_str("\\\""),
687            '\\' => result.push_str("\\\\"),
688            '\n' => result.push_str("\\n"),
689            '\r' => result.push_str("\\r"),
690            '\t' => result.push_str("\\t"),
691            c if c.is_control() => {
692                result.push_str(&format!("\\u{:04x}", c as u32));
693            }
694            c => result.push(c),
695        }
696    }
697    Cow::Owned(result)
698}
699
700/// Escape a string for safe shell usage
701///
702/// Uses single quotes with proper escaping for all special characters.
703/// This ensures values from LLM outputs can be safely used in shell commands.
704///
705/// Example: "Hello 'world'" becomes "'Hello '\''world'\'''"
706pub fn escape_for_shell(s: &str) -> String {
707    // Single-quote escaping: wrap in single quotes, escape existing single quotes
708    // 'foo' -> safe
709    // foo'bar -> 'foo'\''bar'
710    if s.is_empty() {
711        return "''".to_string();
712    }
713
714    let mut result = String::with_capacity(s.len() + 10);
715    result.push('\'');
716
717    for ch in s.chars() {
718        if ch == '\'' {
719            // End current single-quote, add escaped single-quote, start new single-quote
720            result.push_str("'\\''");
721        } else {
722            result.push(ch);
723        }
724    }
725
726    result.push('\'');
727    result
728}
729
730/// Normalize bracket notation to dot notation ONLY inside `{{...}}` blocks
731///
732/// Converts `{{with.items[0]}}` to `{{with.items.0}}` for uniform handling.
733/// This allows users to use familiar JavaScript-style array indexing.
734///
735/// IMPORTANT: Only applies normalization inside template blocks (`{{...}}`),
736/// preserving literal bracket notation in surrounding text (e.g., `data[0]`).
737fn normalize_bracket_notation(template: &str) -> Cow<'_, str> {
738    if !template.contains('[') {
739        return Cow::Borrowed(template);
740    }
741
742    // Check if any bracket notation exists inside {{ }} blocks
743    let mut has_bracket_in_template = false;
744    let mut search_start = 0;
745    while let Some(open) = template[search_start..].find("{{") {
746        let abs_open = search_start + open;
747        if let Some(close) = template[abs_open..].find("}}") {
748            let block = &template[abs_open..abs_open + close + 2];
749            if block.contains('[') {
750                has_bracket_in_template = true;
751                break;
752            }
753            search_start = abs_open + close + 2;
754        } else {
755            break;
756        }
757    }
758
759    if !has_bracket_in_template {
760        return Cow::Borrowed(template);
761    }
762
763    // Rebuild string: copy literal segments verbatim, normalize only inside {{ }}
764    let mut result = String::with_capacity(template.len());
765    let mut pos = 0;
766
767    while pos < template.len() {
768        if let Some(open) = template[pos..].find("{{") {
769            let abs_open = pos + open;
770            // Copy literal text before this {{ block verbatim
771            result.push_str(&template[pos..abs_open]);
772
773            if let Some(close) = template[abs_open..].find("}}") {
774                let abs_close = abs_open + close + 2;
775                let block = &template[abs_open..abs_close];
776                // Normalize brackets only within this {{ }} block
777                let normalized_block = BRACKET_RE.replace_all(block, ".$1");
778                result.push_str(&normalized_block);
779                pos = abs_close;
780            } else {
781                // Unclosed {{ — copy rest verbatim
782                result.push_str(&template[abs_open..]);
783                pos = template.len();
784            }
785        } else {
786            // No more {{ — copy remaining literal text verbatim
787            result.push_str(&template[pos..]);
788            break;
789        }
790    }
791
792    Cow::Owned(result)
793}
794
795/// Resolve all {{with.alias}}, {{context.*}}, and {{inputs.*}} templates
796///
797/// Returns Cow::Borrowed when no templates (zero allocation).
798/// Returns Cow::Owned with single-pass resolution when templates exist.
799///
800/// Performance: Zero-clone traversal - uses references until final value_to_string.
801///
802/// Supports lazy bindings by resolving them on demand via RunContext.
803/// Supports context bindings via {{context.files.alias}} and {{context.session.key}}.
804/// Supports inputs bindings via {{inputs.param}}.
805///
806/// Example: `{{with.forecast}}` → resolved value from bindings
807/// Example: `{{with.flight_info.departure}}` → nested access
808/// Example: `{{context.files.brand}}` → loaded file content
809/// Example: `{{context.session.focus}}` → session data
810/// Example: `{{inputs.topic}}` → input parameter default value
811pub fn resolve<'a>(
812    template: &'a str,
813    bindings: &ResolvedBindings,
814    datastore: &RunContext,
815) -> Result<Cow<'a, str>, NikaError> {
816    // Early return with borrowed string (zero alloc)
817    // Fast check: must contain `{{` followed eventually by `with.`, `context.`, or `inputs.`
818    // Regex handles whitespace variations like `{{ with.` or `{{\twith.`
819    if !template.contains("{{") {
820        return Ok(Cow::Borrowed(template));
821    }
822    let has_with = template.contains("with.");
823    let has_context = template.contains("context.");
824    let has_inputs = template.contains("inputs.");
825    if !has_with && !has_context && !has_inputs {
826        return Ok(Cow::Borrowed(template));
827    }
828
829    // Guard: reject templates with too many variable references
830    let var_count = template.matches("{{").count();
831    if var_count > MAX_TEMPLATE_VARS {
832        return Err(NikaError::TemplateError {
833            template: format!("(template with {} variables)", var_count),
834            reason: format!(
835                "Template contains {} variable references, exceeding the maximum of {}",
836                var_count, MAX_TEMPLATE_VARS
837            ),
838        });
839    }
840
841    // Normalize bracket notation to dot notation
842    // {{with.items[0]}} → {{with.items.0}}
843    let normalized = normalize_bracket_notation(template);
844    let template_str: &str = normalized.as_ref();
845
846    // Single-pass: build result by copying segments + inserting replacements
847    // Uses TEMPLATE_RE (matches ALL {{...}}) + parse_template_expr for full transform support.
848    let mut result = String::with_capacity(template_str.len() + 64);
849    let mut last_end = 0;
850    let mut errors: SmallVec<[String; 4]> = SmallVec::new();
851
852    for cap in TEMPLATE_RE.captures_iter(template_str) {
853        let m = cap.get(0).unwrap();
854        let content = &cap[1];
855
856        // Copy segment before this match
857        result.push_str(&template_str[last_end..m.start()]);
858
859        match parse_template_expr(content) {
860            Ok(TemplateExpr::Alias {
861                ref path,
862                ref transforms,
863            }) => {
864                // Guard: reject pathologically deep alias paths
865                let segment_count = path.split('.').count();
866                if segment_count > MAX_PATH_DEPTH {
867                    return Err(NikaError::TemplateError {
868                        template: path.to_string(),
869                        reason: format!(
870                            "Path depth {} exceeds maximum of {} segments",
871                            segment_count, MAX_PATH_DEPTH
872                        ),
873                    });
874                }
875
876                // Split: first segment is alias, rest is nested path
877                let mut parts = path.split('.');
878                let alias = parts.next().unwrap();
879
880                // Get the resolved value for this alias (supports lazy bindings via RunContext)
881                match bindings.get_resolved(alias, datastore) {
882                    Ok(base_value) => {
883                        // Auto-parse JSON strings so invoke/exec outputs stored
884                        // as Value::String('{"hash":"blake3:..."}') can be
885                        // traversed with {{with.alias.hash}} (NIKA-253 fix).
886                        let effective_base =
887                            crate::binding::jsonpath::try_parse_json_str(&base_value)
888                                .unwrap_or(base_value);
889                        let mut value_ref: &Value = &effective_base;
890                        let mut traversed_segments: SmallVec<[&str; 8]> = SmallVec::new();
891                        traversed_segments.push(alias);
892
893                        // Traverse nested path if present (all by reference)
894                        for segment in parts {
895                            let next = if let Ok(idx) = segment.parse::<usize>() {
896                                value_ref.get(idx)
897                            } else {
898                                value_ref.get(segment)
899                            };
900
901                            match next {
902                                Some(v) => {
903                                    traversed_segments.push(segment);
904                                    value_ref = v;
905                                }
906                                None => {
907                                    let value_type = match value_ref {
908                                        Value::Null => "null",
909                                        Value::Bool(_) => "bool",
910                                        Value::Number(_) => "number",
911                                        Value::String(_) => "string",
912                                        Value::Array(_) => "array",
913                                        Value::Object(_) => "object",
914                                    };
915
916                                    if matches!(value_ref, Value::Object(_) | Value::Array(_)) {
917                                        let traversed_path = traversed_segments.join(".");
918                                        return Err(NikaError::PathNotFound {
919                                            path: format!("{}.{}", traversed_path, segment),
920                                        });
921                                    } else {
922                                        return Err(NikaError::InvalidTraversal {
923                                            segment: segment.to_string(),
924                                            value_type: value_type.to_string(),
925                                            full_path: path.to_string(),
926                                        });
927                                    }
928                                }
929                            }
930                        }
931
932                        // Apply transforms if any, then convert to string
933                        let has_shell = transforms.iter().any(|t| t == "shell");
934
935                        let display = if has_shell {
936                            // Shell transform: apply non-shell transforms first, then escape
937                            let non_shell: Vec<&String> =
938                                transforms.iter().filter(|t| *t != "shell").collect();
939                            let pre_shell_value = if non_shell.is_empty() {
940                                value_ref.clone()
941                            } else {
942                                let transform_str = non_shell
943                                    .iter()
944                                    .map(|s| s.as_str())
945                                    .collect::<Vec<_>>()
946                                    .join(" | ");
947                                let expr =
948                                    crate::binding::transform::TransformExpr::parse(&transform_str)
949                                        .map_err(|e| NikaError::TemplateParse {
950                                            position: m.start(),
951                                            details: format!("Transform parse error: {}", e),
952                                        })?;
953                                expr.apply(value_ref)
954                                    .map_err(|e| NikaError::TemplateParse {
955                                        position: m.start(),
956                                        details: format!("Transform apply error: {}", e),
957                                    })?
958                            };
959                            escape_for_shell(&value_to_display(&pre_shell_value))
960                        } else if !transforms.is_empty() {
961                            // Non-shell transforms: parse and apply chain
962                            let transform_str = transforms.join(" | ");
963                            let expr =
964                                crate::binding::transform::TransformExpr::parse(&transform_str)
965                                    .map_err(|e| NikaError::TemplateParse {
966                                        position: m.start(),
967                                        details: format!("Transform parse error: {}", e),
968                                    })?;
969                            let final_value =
970                                expr.apply(value_ref)
971                                    .map_err(|e| NikaError::TemplateParse {
972                                        position: m.start(),
973                                        details: format!("Transform apply error: {}", e),
974                                    })?;
975                            if is_in_json_context(template_str, m.start()) {
976                                escape_for_json(&value_to_display(&final_value)).into_owned()
977                            } else {
978                                value_to_display(&final_value).into_owned()
979                            }
980                        } else {
981                            // No transforms: use strict value_to_string (null = error)
982                            let replacement = value_to_string(value_ref, path, alias)?;
983                            if is_in_json_context(template_str, m.start()) {
984                                escape_for_json(&replacement).into_owned()
985                            } else {
986                                replacement.into_owned()
987                            }
988                        };
989
990                        result.push_str(&display);
991                    }
992                    Err(_) => {
993                        errors.push(alias.to_string());
994                    }
995                }
996            }
997            Ok(TemplateExpr::Context { .. } | TemplateExpr::Input { .. }) => {
998                // Leave context/inputs refs for Pass 2 — re-emit as {{...}}
999                result.push_str(&format!("{{{{{}}}}}", content.trim()));
1000            }
1001            Err(_) => {
1002                // Malformed expression — re-emit literally
1003                result.push_str(m.as_str());
1004            }
1005        }
1006
1007        last_end = m.end();
1008    }
1009
1010    if !errors.is_empty() {
1011        return Err(NikaError::TemplateError {
1012            template: errors.join(", "),
1013            reason: "Alias(es) not resolved. Did you declare them in 'with:'?".to_string(),
1014        });
1015    }
1016
1017    // Copy remaining segment after last match
1018    result.push_str(&template_str[last_end..]);
1019
1020    // ─────────────────────────────────────────────────────────────
1021    // Pass 2: Resolve {{context.files.alias}} and {{context.session.key}}
1022    // ─────────────────────────────────────────────────────────────
1023    if has_context && result.contains("context.") {
1024        let intermediate = std::mem::take(&mut result);
1025        result = String::with_capacity(intermediate.len() + 64);
1026        let mut last_end = 0;
1027        let mut context_errors: SmallVec<[String; 4]> = SmallVec::new();
1028
1029        for cap in TEMPLATE_RE.captures_iter(&intermediate) {
1030            let m = cap.get(0).unwrap();
1031            let inner = cap[1].trim();
1032            let (path, transforms) = match parse_template_expr(inner) {
1033                Ok(TemplateExpr::Context { path, transforms }) => (path, transforms),
1034                _ => continue,
1035            };
1036            result.push_str(&intermediate[last_end..m.start()]);
1037            let full_path = format!("context.{}", path);
1038            match datastore.resolve_context_path(&full_path) {
1039                Some(value) => {
1040                    let replacement = if !transforms.is_empty() {
1041                        let transform_str = transforms.join(" | ");
1042                        let expr = TransformExpr::parse(&transform_str).map_err(|e| {
1043                            NikaError::TemplateParse {
1044                                position: m.start(),
1045                                details: format!("Transform parse error: {}", e),
1046                            }
1047                        })?;
1048                        let transformed =
1049                            expr.apply(&value).map_err(|e| NikaError::TemplateParse {
1050                                position: m.start(),
1051                                details: format!("Transform apply error: {}", e),
1052                            })?;
1053                        if is_in_json_context(&intermediate, m.start()) {
1054                            escape_for_json(&value_to_display(&transformed)).into_owned()
1055                        } else {
1056                            value_to_display(&transformed).into_owned()
1057                        }
1058                    } else {
1059                        let s = context_value_to_string(&value, &full_path)?;
1060                        if is_in_json_context(&intermediate, m.start()) {
1061                            escape_for_json(&s).into_owned()
1062                        } else {
1063                            s.into_owned()
1064                        }
1065                    };
1066                    result.push_str(&replacement);
1067                }
1068                None => {
1069                    context_errors.push(full_path);
1070                }
1071            }
1072
1073            last_end = m.end();
1074        }
1075
1076        if !context_errors.is_empty() {
1077            return Err(NikaError::TemplateError {
1078                template: context_errors.join(", "),
1079                reason: "Context binding(s) not resolved. Check your 'context:' block in workflow."
1080                    .to_string(),
1081            });
1082        }
1083
1084        // Copy remaining segment
1085        result.push_str(&intermediate[last_end..]);
1086
1087        // Continue to inputs pass if needed
1088        if !has_inputs || !result.contains("inputs.") {
1089            return Ok(Cow::Owned(result));
1090        }
1091        // Fall through to inputs pass with updated result
1092    }
1093
1094    // ─────────────────────────────────────────────────────────────
1095    // Pass 3: Resolve {{inputs.param}}
1096    // ─────────────────────────────────────────────────────────────
1097    if has_inputs && result.contains("inputs.") {
1098        let intermediate = std::mem::take(&mut result);
1099        result = String::with_capacity(intermediate.len() + 64);
1100        let mut last_end = 0;
1101        let mut input_errors: SmallVec<[String; 4]> = SmallVec::new();
1102
1103        for cap in TEMPLATE_RE.captures_iter(&intermediate) {
1104            let m = cap.get(0).unwrap();
1105            let inner = cap[1].trim();
1106            let (path, transforms) = match parse_template_expr(inner) {
1107                Ok(TemplateExpr::Input { path, transforms }) => (path, transforms),
1108                _ => continue,
1109            };
1110            result.push_str(&intermediate[last_end..m.start()]);
1111            let full_path = format!("inputs.{}", path);
1112            match datastore.resolve_input_path(&full_path) {
1113                Some(value) => {
1114                    let replacement = if !transforms.is_empty() {
1115                        let transform_str = transforms.join(" | ");
1116                        let expr = TransformExpr::parse(&transform_str).map_err(|e| {
1117                            NikaError::TemplateParse {
1118                                position: m.start(),
1119                                details: format!("Transform parse error: {}", e),
1120                            }
1121                        })?;
1122                        let transformed =
1123                            expr.apply(&value).map_err(|e| NikaError::TemplateParse {
1124                                position: m.start(),
1125                                details: format!("Transform apply error: {}", e),
1126                            })?;
1127                        if is_in_json_context(&intermediate, m.start()) {
1128                            escape_for_json(&value_to_display(&transformed)).into_owned()
1129                        } else {
1130                            value_to_display(&transformed).into_owned()
1131                        }
1132                    } else {
1133                        let s = input_value_to_string(&value, &full_path)?;
1134                        if is_in_json_context(&intermediate, m.start()) {
1135                            escape_for_json(&s).into_owned()
1136                        } else {
1137                            s.into_owned()
1138                        }
1139                    };
1140                    result.push_str(&replacement);
1141                }
1142                None => {
1143                    input_errors.push(full_path);
1144                }
1145            }
1146
1147            last_end = m.end();
1148        }
1149
1150        if !input_errors.is_empty() {
1151            return Err(NikaError::TemplateError {
1152                template: input_errors.join(", "),
1153                reason: "Input binding(s) not resolved. Check your 'inputs:' block in workflow or provide defaults.".to_string(),
1154            });
1155        }
1156
1157        // Copy remaining segment
1158        result.push_str(&intermediate[last_end..]);
1159
1160        return Ok(Cow::Owned(result));
1161    }
1162
1163    Ok(Cow::Owned(result))
1164}
1165
1166/// Resolve templates for shell context
1167///
1168/// Similar to `resolve`, but shell-escapes all substituted values to prevent
1169/// command injection from LLM outputs containing special characters.
1170///
1171/// Example: `echo 'Hello {{with.msg}}'` with msg="Nika's test" becomes
1172///          `echo 'Hello '\''Nika'\''s test'\'''`
1173pub fn resolve_for_shell<'a>(
1174    template: &'a str,
1175    bindings: &ResolvedBindings,
1176    datastore: &RunContext,
1177) -> Result<Cow<'a, str>, NikaError> {
1178    // Early return if no templates
1179    if !template.contains("{{") {
1180        return Ok(Cow::Borrowed(template));
1181    }
1182    let has_with = template.contains("with.");
1183    let has_context = template.contains("context.");
1184    let has_inputs = template.contains("inputs.");
1185    if !has_with && !has_context && !has_inputs {
1186        return Ok(Cow::Borrowed(template));
1187    }
1188
1189    // Normalize bracket notation: {{with.items[0]}} → {{with.items.0}}
1190    let normalized = normalize_bracket_notation(template);
1191    let template_str: &str = normalized.as_ref();
1192
1193    // Pass 1: Alias bindings (shell-escaped)
1194    // Uses TEMPLATE_RE + parse_template_expr for full transform support.
1195    let mut result = String::with_capacity(template_str.len() + 64);
1196    let mut last_end = 0;
1197    let mut errors: SmallVec<[String; 4]> = SmallVec::new();
1198
1199    for cap in TEMPLATE_RE.captures_iter(template_str) {
1200        let m = cap.get(0).unwrap();
1201        let content = &cap[1];
1202
1203        let (path, transforms) = match parse_template_expr(content) {
1204            Ok(TemplateExpr::Alias { path, transforms }) => (path, transforms),
1205            _ => continue,
1206        };
1207
1208        result.push_str(&template_str[last_end..m.start()]);
1209
1210        let mut parts = path.split('.');
1211        let alias = parts.next().unwrap();
1212
1213        match bindings.get_resolved(alias, datastore) {
1214            Ok(base_value) => {
1215                // Auto-parse JSON strings (NIKA-253 fix, same as resolve()).
1216                let effective_base =
1217                    crate::binding::jsonpath::try_parse_json_str(&base_value).unwrap_or(base_value);
1218                let mut value_ref: &Value = &effective_base;
1219                let mut traversed_segments: SmallVec<[&str; 8]> = SmallVec::new();
1220                traversed_segments.push(alias);
1221
1222                for segment in parts {
1223                    let next = if let Ok(idx) = segment.parse::<usize>() {
1224                        value_ref.get(idx)
1225                    } else {
1226                        value_ref.get(segment)
1227                    };
1228
1229                    match next {
1230                        Some(v) => {
1231                            traversed_segments.push(segment);
1232                            value_ref = v;
1233                        }
1234                        None => {
1235                            let value_type = match value_ref {
1236                                Value::Null => "null",
1237                                Value::Bool(_) => "bool",
1238                                Value::Number(_) => "number",
1239                                Value::String(_) => "string",
1240                                Value::Array(_) => "array",
1241                                Value::Object(_) => "object",
1242                            };
1243
1244                            if matches!(value_ref, Value::Object(_) | Value::Array(_)) {
1245                                let traversed_path = traversed_segments.join(".");
1246                                return Err(NikaError::PathNotFound {
1247                                    path: format!("{}.{}", traversed_path, segment),
1248                                });
1249                            } else {
1250                                return Err(NikaError::InvalidTraversal {
1251                                    segment: segment.to_string(),
1252                                    value_type: value_type.to_string(),
1253                                    full_path: path.to_string(),
1254                                });
1255                            }
1256                        }
1257                    }
1258                }
1259
1260                // Apply non-shell transforms first, then shell-escape the result.
1261                let has_shell = transforms.iter().any(|t| t == "shell");
1262                let non_shell: Vec<&String> = transforms.iter().filter(|t| *t != "shell").collect();
1263
1264                let raw_value = if !non_shell.is_empty() {
1265                    let transform_str = non_shell
1266                        .iter()
1267                        .map(|s| s.as_str())
1268                        .collect::<Vec<_>>()
1269                        .join(" | ");
1270                    let expr = crate::binding::transform::TransformExpr::parse(&transform_str)
1271                        .map_err(|e| NikaError::TemplateParse {
1272                            position: m.start(),
1273                            details: format!("Transform parse error: {}", e),
1274                        })?;
1275                    let transformed =
1276                        expr.apply(value_ref)
1277                            .map_err(|e| NikaError::TemplateParse {
1278                                position: m.start(),
1279                                details: format!("Transform apply error: {}", e),
1280                            })?;
1281                    value_to_display(&transformed).into_owned()
1282                } else if has_shell {
1283                    // Only |shell, no other transforms: use strict value_to_string
1284                    value_to_string(value_ref, &path, alias)?.into_owned()
1285                } else {
1286                    // No transforms at all: use strict value_to_string
1287                    value_to_string(value_ref, &path, alias)?.into_owned()
1288                };
1289
1290                // Shell-escape the value
1291                let escaped = escape_for_shell(&raw_value);
1292                result.push_str(&escaped);
1293            }
1294            Err(_) => {
1295                errors.push(alias.to_string());
1296            }
1297        }
1298
1299        last_end = m.end();
1300    }
1301
1302    if !errors.is_empty() {
1303        return Err(NikaError::TemplateError {
1304            template: errors.join(", "),
1305            reason: "Alias(es) not resolved. Did you declare them in 'with:'?".to_string(),
1306        });
1307    }
1308
1309    result.push_str(&template_str[last_end..]);
1310
1311    // Pass 2: Context bindings (shell-escaped)
1312    if has_context && result.contains("context.") {
1313        let intermediate = std::mem::take(&mut result);
1314        result = String::with_capacity(intermediate.len() + 64);
1315        let mut last_end = 0;
1316        let mut context_errors: SmallVec<[String; 4]> = SmallVec::new();
1317
1318        for cap in TEMPLATE_RE.captures_iter(&intermediate) {
1319            let m = cap.get(0).unwrap();
1320            let inner = cap[1].trim();
1321            let (path, transforms) = match parse_template_expr(inner) {
1322                Ok(TemplateExpr::Context { path, transforms }) => (path, transforms),
1323                _ => continue,
1324            };
1325            result.push_str(&intermediate[last_end..m.start()]);
1326            let full_path = format!("context.{}", path);
1327            match datastore.resolve_context_path(&full_path) {
1328                Some(value) => {
1329                    let raw_value = if !transforms.is_empty() {
1330                        let transform_str = transforms.join(" | ");
1331                        let expr = TransformExpr::parse(&transform_str).map_err(|e| {
1332                            NikaError::TemplateParse {
1333                                position: m.start(),
1334                                details: format!("Transform parse error: {}", e),
1335                            }
1336                        })?;
1337                        let transformed =
1338                            expr.apply(&value).map_err(|e| NikaError::TemplateParse {
1339                                position: m.start(),
1340                                details: format!("Transform apply error: {}", e),
1341                            })?;
1342                        value_to_display(&transformed).into_owned()
1343                    } else {
1344                        context_value_to_string(&value, &full_path)?.into_owned()
1345                    };
1346                    let escaped = escape_for_shell(&raw_value);
1347                    result.push_str(&escaped);
1348                }
1349                None => {
1350                    context_errors.push(full_path);
1351                }
1352            }
1353
1354            last_end = m.end();
1355        }
1356
1357        if !context_errors.is_empty() {
1358            return Err(NikaError::TemplateError {
1359                template: context_errors.join(", "),
1360                reason: "Context binding(s) not resolved. Check your 'context:' block in workflow."
1361                    .to_string(),
1362            });
1363        }
1364
1365        result.push_str(&intermediate[last_end..]);
1366    }
1367
1368    // Pass 3: Input bindings (shell-escaped)
1369    if has_inputs && result.contains("inputs.") {
1370        let intermediate = std::mem::take(&mut result);
1371        result = String::with_capacity(intermediate.len() + 64);
1372        let mut last_end = 0;
1373        let mut input_errors: SmallVec<[String; 4]> = SmallVec::new();
1374
1375        for cap in TEMPLATE_RE.captures_iter(&intermediate) {
1376            let m = cap.get(0).unwrap();
1377            let inner = cap[1].trim();
1378            let (path, transforms) = match parse_template_expr(inner) {
1379                Ok(TemplateExpr::Input { path, transforms }) => (path, transforms),
1380                _ => continue,
1381            };
1382            result.push_str(&intermediate[last_end..m.start()]);
1383            let full_path = format!("inputs.{}", path);
1384            match datastore.resolve_input_path(&full_path) {
1385                Some(value) => {
1386                    let raw_value = if !transforms.is_empty() {
1387                        let transform_str = transforms.join(" | ");
1388                        let expr = TransformExpr::parse(&transform_str).map_err(|e| {
1389                            NikaError::TemplateParse {
1390                                position: m.start(),
1391                                details: format!("Transform parse error: {}", e),
1392                            }
1393                        })?;
1394                        let transformed =
1395                            expr.apply(&value).map_err(|e| NikaError::TemplateParse {
1396                                position: m.start(),
1397                                details: format!("Transform apply error: {}", e),
1398                            })?;
1399                        value_to_display(&transformed).into_owned()
1400                    } else {
1401                        input_value_to_string(&value, &full_path)?.into_owned()
1402                    };
1403                    let escaped = escape_for_shell(&raw_value);
1404                    result.push_str(&escaped);
1405                }
1406                None => {
1407                    input_errors.push(full_path);
1408                }
1409            }
1410
1411            last_end = m.end();
1412        }
1413
1414        if !input_errors.is_empty() {
1415            return Err(NikaError::TemplateError {
1416                template: input_errors.join(", "),
1417                reason: "Input binding(s) not resolved. Check your 'inputs:' block in workflow or provide defaults.".to_string(),
1418            });
1419        }
1420
1421        result.push_str(&intermediate[last_end..]);
1422    }
1423
1424    Ok(Cow::Owned(result))
1425}
1426
1427/// Convert JSON Value to string for template substitution (strict mode)
1428///
1429/// Returns `Cow::Borrowed` for string values (avoids cloning).
1430/// Returns error for null values - this prevents silent bugs from missing data.
1431fn value_to_string<'a>(
1432    value: &'a Value,
1433    path: &str,
1434    alias: &str,
1435) -> Result<Cow<'a, str>, NikaError> {
1436    match value {
1437        Value::String(s) => Ok(Cow::Borrowed(s.as_str())),
1438        Value::Null => Err(NikaError::NullValue {
1439            path: path.to_string(),
1440            alias: alias.to_string(),
1441        }),
1442        Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
1443        Value::Number(n) => Ok(Cow::Owned(n.to_string())),
1444        // For objects/arrays, return compact JSON representation
1445        other => Ok(Cow::Owned(other.to_string())),
1446    }
1447}
1448
1449/// Convert context Value to string for template substitution
1450///
1451/// Returns `Cow::Borrowed` for string values (avoids cloning).
1452fn context_value_to_string<'a>(value: &'a Value, path: &str) -> Result<Cow<'a, str>, NikaError> {
1453    match value {
1454        Value::String(s) => Ok(Cow::Borrowed(s.as_str())),
1455        Value::Null => Err(NikaError::TemplateError {
1456            template: path.to_string(),
1457            reason: "Context binding resolved to null".to_string(),
1458        }),
1459        Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
1460        Value::Number(n) => Ok(Cow::Owned(n.to_string())),
1461        // For objects/arrays, return compact JSON representation
1462        other => Ok(Cow::Owned(other.to_string())),
1463    }
1464}
1465
1466/// Convert input Value to string for template substitution
1467///
1468/// Returns `Cow::Borrowed` for string values (avoids cloning).
1469fn input_value_to_string<'a>(value: &'a Value, path: &str) -> Result<Cow<'a, str>, NikaError> {
1470    match value {
1471        Value::String(s) => Ok(Cow::Borrowed(s.as_str())),
1472        Value::Null => Err(NikaError::TemplateError {
1473            template: path.to_string(),
1474            reason: "Input binding resolved to null. Provide a 'default' value in your inputs definition.".to_string(),
1475        }),
1476        Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
1477        Value::Number(n) => Ok(Cow::Owned(n.to_string())),
1478        // For objects/arrays, return compact JSON representation
1479        other => Ok(Cow::Owned(other.to_string())),
1480    }
1481}
1482
1483/// Check if position is inside a JSON string
1484fn is_in_json_context(template: &str, pos: usize) -> bool {
1485    // First check: the template must look like a JSON structure at the top level.
1486    // A template starting with `{` (after whitespace) indicates JSON object context.
1487    // This avoids false positives from natural language with unbalanced quotes
1488    // like: He said "hello {{with.msg}}"
1489    let trimmed = template.trim_start();
1490    let looks_like_json = trimmed.starts_with('{') || trimmed.starts_with('[');
1491    if !looks_like_json {
1492        return false;
1493    }
1494
1495    // Second check: count quote parity to determine if we're inside a JSON string value
1496    let before = &template[..pos];
1497    let mut in_string = false;
1498    let mut escaped = false;
1499
1500    for ch in before.chars() {
1501        if escaped {
1502            escaped = false;
1503            continue;
1504        }
1505        match ch {
1506            '\\' => escaped = true,
1507            '"' => in_string = !in_string,
1508            _ => {}
1509        }
1510    }
1511
1512    in_string
1513}
1514
1515/// Extract all alias references from a template (for static validation)
1516///
1517/// Returns a Vec of (alias, full_path) tuples.
1518/// Example: "{{with.weather.temp}}" → vec![("weather", "weather.temp")]
1519pub fn extract_refs(template: &str) -> Vec<(String, String)> {
1520    USE_RE
1521        .captures_iter(template)
1522        .map(|cap| {
1523            let full_path = cap[1].to_string();
1524            let alias = full_path.split('.').next().unwrap().to_string();
1525            (alias, full_path)
1526        })
1527        .collect()
1528}
1529
1530/// Validate that all template references exist in declared aliases (static validation)
1531///
1532/// This is called by `nika validate` before runtime.
1533/// Returns Ok(()) if valid, Err with first unknown alias if not.
1534pub fn validate_refs(
1535    template: &str,
1536    declared_aliases: &FxHashSet<String>,
1537    task_id: &str,
1538) -> Result<(), NikaError> {
1539    for (alias, _full_path) in extract_refs(template) {
1540        if !declared_aliases.contains(&alias) {
1541            return Err(NikaError::UnknownAlias {
1542                alias,
1543                task_id: task_id.to_string(),
1544            });
1545        }
1546    }
1547    Ok(())
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552    use super::*;
1553    use serde_json::json;
1554    use std::borrow::Cow;
1555
1556    /// Helper to create empty datastore for tests
1557    fn empty_datastore() -> RunContext {
1558        RunContext::new()
1559    }
1560
1561    #[test]
1562    fn resolve_simple() {
1563        let mut bindings = ResolvedBindings::new();
1564        bindings.set("forecast", json!("Sunny 25C"));
1565        let ds = empty_datastore();
1566
1567        let result = resolve("Weather: {{with.forecast}}", &bindings, &ds).unwrap();
1568        assert_eq!(result, "Weather: Sunny 25C");
1569    }
1570
1571    #[test]
1572    fn resolve_number() {
1573        let mut bindings = ResolvedBindings::new();
1574        bindings.set("price", json!(89));
1575        let ds = empty_datastore();
1576
1577        let result = resolve("Price: ${{with.price}}", &bindings, &ds).unwrap();
1578        assert_eq!(result, "Price: $89");
1579    }
1580
1581    #[test]
1582    fn resolve_nested() {
1583        let mut bindings = ResolvedBindings::new();
1584        bindings.set("flight_info", json!({"departure": "10:30", "gate": "A12"}));
1585        let ds = empty_datastore();
1586
1587        let result = resolve("Depart at {{with.flight_info.departure}}", &bindings, &ds).unwrap();
1588        assert_eq!(result, "Depart at 10:30");
1589    }
1590
1591    #[test]
1592    fn resolve_multiple() {
1593        let mut bindings = ResolvedBindings::new();
1594        bindings.set("a", json!("first"));
1595        bindings.set("b", json!("second"));
1596        let ds = empty_datastore();
1597
1598        let result = resolve("{{with.a}} and {{with.b}}", &bindings, &ds).unwrap();
1599        assert_eq!(result, "first and second");
1600    }
1601
1602    #[test]
1603    fn resolve_object() {
1604        let mut bindings = ResolvedBindings::new();
1605        bindings.set("data", json!({"x": 1, "y": 2}));
1606        let ds = empty_datastore();
1607
1608        let result = resolve("Full: {{with.data}}", &bindings, &ds).unwrap();
1609        // Object is serialized as JSON
1610        assert!(result.contains("\"x\":1") || result.contains("\"x\": 1"));
1611    }
1612
1613    #[test]
1614    fn resolve_alias_not_found() {
1615        let mut bindings = ResolvedBindings::new();
1616        bindings.set("known", json!("value"));
1617        let ds = empty_datastore();
1618
1619        let result = resolve("{{with.unknown}}", &bindings, &ds);
1620        assert!(result.is_err());
1621        assert!(result.unwrap_err().to_string().contains("unknown"));
1622    }
1623
1624    #[test]
1625    fn resolve_path_not_found() {
1626        let mut bindings = ResolvedBindings::new();
1627        bindings.set("data", json!({"a": 1}));
1628        let ds = empty_datastore();
1629
1630        let result = resolve("{{with.data.nonexistent}}", &bindings, &ds);
1631        assert!(result.is_err());
1632    }
1633
1634    #[test]
1635    fn resolve_no_templates() {
1636        let bindings = ResolvedBindings::new();
1637        let ds = empty_datastore();
1638        let result = resolve("No templates here", &bindings, &ds).unwrap();
1639        assert_eq!(result, "No templates here");
1640        // Verify zero-alloc: should be Cow::Borrowed
1641        assert!(matches!(result, Cow::Borrowed(_)));
1642    }
1643
1644    #[test]
1645    fn resolve_with_templates_is_owned() {
1646        let mut bindings = ResolvedBindings::new();
1647        bindings.set("x", json!("value"));
1648        let ds = empty_datastore();
1649        let result = resolve("Has {{with.x}} template", &bindings, &ds).unwrap();
1650        assert_eq!(result, "Has value template");
1651        // With templates: should be Cow::Owned
1652        assert!(matches!(result, Cow::Owned(_)));
1653    }
1654
1655    #[test]
1656    fn resolve_array_index() {
1657        let mut bindings = ResolvedBindings::new();
1658        bindings.set("items", json!(["first", "second", "third"]));
1659        let ds = empty_datastore();
1660
1661        let result = resolve("Item: {{with.items.0}}", &bindings, &ds).unwrap();
1662        assert_eq!(result, "Item: first");
1663    }
1664
1665    // ─────────────────────────────────────────────────────────────
1666    // Bracket notation tests (array indexing with [N])
1667    // ─────────────────────────────────────────────────────────────
1668
1669    #[test]
1670    fn resolve_bracket_notation_simple() {
1671        let mut bindings = ResolvedBindings::new();
1672        bindings.set("items", json!(["first", "second", "third"]));
1673        let ds = empty_datastore();
1674
1675        // Bracket notation should work like dot notation
1676        let result = resolve("Item: {{with.items[0]}}", &bindings, &ds).unwrap();
1677        assert_eq!(result, "Item: first");
1678    }
1679
1680    #[test]
1681    fn resolve_bracket_notation_second_element() {
1682        let mut bindings = ResolvedBindings::new();
1683        bindings.set("items", json!(["first", "second", "third"]));
1684        let ds = empty_datastore();
1685
1686        let result = resolve("Item: {{with.items[1]}}", &bindings, &ds).unwrap();
1687        assert_eq!(result, "Item: second");
1688    }
1689
1690    #[test]
1691    fn resolve_bracket_notation_nested() {
1692        let mut bindings = ResolvedBindings::new();
1693        bindings.set(
1694            "data",
1695            json!({
1696                "user": {"name": "Alice", "address": {"city": "Paris"}},
1697                "items": ["one", "two", "three"]
1698            }),
1699        );
1700        let ds = empty_datastore();
1701
1702        // Nested object + bracket notation for array
1703        let result = resolve("First item: {{with.data.items[0]}}", &bindings, &ds).unwrap();
1704        assert_eq!(result, "First item: one");
1705    }
1706
1707    #[test]
1708    fn resolve_bracket_notation_mixed_syntax() {
1709        let mut bindings = ResolvedBindings::new();
1710        bindings.set(
1711            "data",
1712            json!({"users": [{"name": "Alice"}, {"name": "Bob"}]}),
1713        );
1714        let ds = empty_datastore();
1715
1716        // Mix of dot and bracket notation
1717        let result = resolve("User: {{with.data.users[0].name}}", &bindings, &ds).unwrap();
1718        assert_eq!(result, "User: Alice");
1719    }
1720
1721    #[test]
1722    fn resolve_bracket_notation_multiple() {
1723        let mut bindings = ResolvedBindings::new();
1724        bindings.set("items", json!(["a", "b", "c"]));
1725        let ds = empty_datastore();
1726
1727        // Multiple bracket notations in one template
1728        let result = resolve("{{with.items[0]}} and {{with.items[2]}}", &bindings, &ds).unwrap();
1729        assert_eq!(result, "a and c");
1730    }
1731
1732    #[test]
1733    fn normalize_bracket_notation_unit() {
1734        // Direct test of the normalization function
1735        assert_eq!(
1736            normalize_bracket_notation("{{with.items[0]}}"),
1737            "{{with.items.0}}"
1738        );
1739        assert_eq!(
1740            normalize_bracket_notation("{{with.data.items[1].name}}"),
1741            "{{with.data.items.1.name}}"
1742        );
1743        assert_eq!(
1744            normalize_bracket_notation("no brackets here"),
1745            "no brackets here"
1746        );
1747        // Multiple brackets
1748        assert_eq!(
1749            normalize_bracket_notation("{{with.a[0]}} and {{with.b[2]}}"),
1750            "{{with.a.0}} and {{with.b.2}}"
1751        );
1752    }
1753
1754    // ─────────────────────────────────────────────────────────────
1755    // Strict mode tests
1756    // ─────────────────────────────────────────────────────────────
1757
1758    #[test]
1759    fn resolve_null_is_error() {
1760        let mut bindings = ResolvedBindings::new();
1761        bindings.set("data", json!(null));
1762        let ds = empty_datastore();
1763
1764        let result = resolve("Value: {{with.data}}", &bindings, &ds);
1765        assert!(result.is_err());
1766        let err = result.unwrap_err();
1767        assert!(err.to_string().contains("NIKA-072"));
1768        assert!(err.to_string().contains("Null value"));
1769    }
1770
1771    #[test]
1772    fn resolve_nested_null_is_error() {
1773        let mut bindings = ResolvedBindings::new();
1774        bindings.set("data", json!({"value": null}));
1775        let ds = empty_datastore();
1776
1777        let result = resolve("Value: {{with.data.value}}", &bindings, &ds);
1778        assert!(result.is_err());
1779        assert!(result.unwrap_err().to_string().contains("NIKA-072"));
1780    }
1781
1782    #[test]
1783    fn resolve_invalid_traversal_on_string() {
1784        let mut bindings = ResolvedBindings::new();
1785        bindings.set("data", json!("just a string"));
1786        let ds = empty_datastore();
1787
1788        let result = resolve("{{with.data.field}}", &bindings, &ds);
1789        assert!(result.is_err());
1790        let err = result.unwrap_err();
1791        assert!(err.to_string().contains("NIKA-073"));
1792        assert!(err.to_string().contains("string"));
1793    }
1794
1795    #[test]
1796    fn resolve_invalid_traversal_on_number() {
1797        let mut bindings = ResolvedBindings::new();
1798        bindings.set("price", json!(42));
1799        let ds = empty_datastore();
1800
1801        let result = resolve("{{with.price.currency}}", &bindings, &ds);
1802        assert!(result.is_err());
1803        let err = result.unwrap_err();
1804        assert!(err.to_string().contains("NIKA-073"));
1805        assert!(err.to_string().contains("number"));
1806    }
1807
1808    // ─────────────────────────────────────────────────────────────
1809    // Static validation tests
1810    // ─────────────────────────────────────────────────────────────
1811
1812    #[test]
1813    fn extract_refs_simple() {
1814        let refs = extract_refs("Hello {{with.weather}}!");
1815        assert_eq!(refs.len(), 1);
1816        assert_eq!(refs[0], ("weather".to_string(), "weather".to_string()));
1817    }
1818
1819    #[test]
1820    fn extract_refs_nested() {
1821        let refs = extract_refs("{{with.data.field.sub}}");
1822        assert_eq!(refs.len(), 1);
1823        assert_eq!(refs[0], ("data".to_string(), "data.field.sub".to_string()));
1824    }
1825
1826    #[test]
1827    fn extract_refs_multiple() {
1828        let refs = extract_refs("{{with.a}} and {{with.b.c}}");
1829        assert_eq!(refs.len(), 2);
1830        assert_eq!(refs[0].0, "a");
1831        assert_eq!(refs[1].0, "b");
1832    }
1833
1834    #[test]
1835    fn extract_refs_none() {
1836        let refs = extract_refs("No templates here");
1837        assert!(refs.is_empty());
1838    }
1839
1840    #[test]
1841    fn validate_refs_success() {
1842        let declared: FxHashSet<String> =
1843            ["weather", "price"].iter().map(|s| s.to_string()).collect();
1844        let result = validate_refs("{{with.weather}} costs {{with.price}}", &declared, "task1");
1845        assert!(result.is_ok());
1846    }
1847
1848    #[test]
1849    fn validate_refs_unknown_alias() {
1850        let declared: FxHashSet<String> = ["weather"].iter().map(|s| s.to_string()).collect();
1851        let result = validate_refs("{{with.weather}} and {{with.unknown}}", &declared, "task1");
1852        assert!(result.is_err());
1853        let err = result.unwrap_err();
1854        assert!(err.to_string().contains("NIKA-071"));
1855        assert!(err.to_string().contains("unknown"));
1856    }
1857
1858    // ─────────────────────────────────────────────────────────────
1859    // Context binding tests
1860    // ─────────────────────────────────────────────────────────────
1861
1862    use crate::store::LoadedContext;
1863
1864    /// Helper to create datastore with context for tests
1865    fn datastore_with_context() -> RunContext {
1866        let store = RunContext::new();
1867        let mut context = LoadedContext::new();
1868        context.files.insert(
1869            "brand".to_string(),
1870            json!("# QR Code AI\nTagline: Scan smarter"),
1871        );
1872        context
1873            .files
1874            .insert("config".to_string(), json!({"theme": "dark", "version": 2}));
1875        context.session = Some(json!({"focus": "rust", "level": 3}));
1876        store.set_context(context);
1877        store
1878    }
1879
1880    #[test]
1881    fn resolve_context_files_simple() {
1882        let bindings = ResolvedBindings::new();
1883        let ds = datastore_with_context();
1884
1885        let result = resolve("Brand: {{context.files.brand}}", &bindings, &ds).unwrap();
1886        assert_eq!(result, "Brand: # QR Code AI\nTagline: Scan smarter");
1887    }
1888
1889    #[test]
1890    fn resolve_context_files_nested() {
1891        let bindings = ResolvedBindings::new();
1892        let ds = datastore_with_context();
1893
1894        let result = resolve("Theme: {{context.files.config.theme}}", &bindings, &ds).unwrap();
1895        assert_eq!(result, "Theme: dark");
1896    }
1897
1898    #[test]
1899    fn resolve_context_session() {
1900        let bindings = ResolvedBindings::new();
1901        let ds = datastore_with_context();
1902
1903        let result = resolve("Focus: {{context.session.focus}}", &bindings, &ds).unwrap();
1904        assert_eq!(result, "Focus: rust");
1905    }
1906
1907    #[test]
1908    fn resolve_context_session_number() {
1909        let bindings = ResolvedBindings::new();
1910        let ds = datastore_with_context();
1911
1912        let result = resolve("Level: {{context.session.level}}", &bindings, &ds).unwrap();
1913        assert_eq!(result, "Level: 3");
1914    }
1915
1916    #[test]
1917    fn resolve_context_with_use_bindings() {
1918        let mut bindings = ResolvedBindings::new();
1919        bindings.set("greeting", json!("Hello"));
1920        let ds = datastore_with_context();
1921
1922        let result = resolve(
1923            "{{with.greeting}}! Brand: {{context.files.brand}}",
1924            &bindings,
1925            &ds,
1926        )
1927        .unwrap();
1928        assert_eq!(result, "Hello! Brand: # QR Code AI\nTagline: Scan smarter");
1929    }
1930
1931    #[test]
1932    fn resolve_context_not_found() {
1933        let bindings = ResolvedBindings::new();
1934        let ds = datastore_with_context();
1935
1936        let result = resolve("{{context.files.nonexistent}}", &bindings, &ds);
1937        assert!(result.is_err());
1938        let err = result.unwrap_err();
1939        assert!(err.to_string().contains("Context binding"));
1940        assert!(err.to_string().contains("nonexistent"));
1941    }
1942
1943    #[test]
1944    fn resolve_context_no_context_loaded() {
1945        let bindings = ResolvedBindings::new();
1946        let ds = empty_datastore(); // No context loaded
1947
1948        let result = resolve("{{context.files.brand}}", &bindings, &ds);
1949        assert!(result.is_err());
1950    }
1951
1952    #[test]
1953    fn resolve_only_context_no_use() {
1954        let bindings = ResolvedBindings::new();
1955        let ds = datastore_with_context();
1956
1957        // Template with ONLY context bindings, no use bindings
1958        let result = resolve("Theme is {{context.files.config.theme}}", &bindings, &ds).unwrap();
1959        assert_eq!(result, "Theme is dark");
1960    }
1961
1962    #[test]
1963    fn resolve_context_preserves_no_template() {
1964        let bindings = ResolvedBindings::new();
1965        let ds = datastore_with_context();
1966
1967        // No templates at all
1968        let result = resolve("Plain text without templates", &bindings, &ds).unwrap();
1969        assert_eq!(result, "Plain text without templates");
1970        // Should be borrowed (zero alloc)
1971        assert!(matches!(result, Cow::Borrowed(_)));
1972    }
1973
1974    // ═══════════════════════════════════════════════════════════════════════════
1975    // Shell escaping tests
1976    // ═══════════════════════════════════════════════════════════════════════════
1977
1978    #[test]
1979    fn escape_for_shell_simple() {
1980        assert_eq!(escape_for_shell("hello"), "'hello'");
1981    }
1982
1983    #[test]
1984    fn escape_for_shell_empty() {
1985        assert_eq!(escape_for_shell(""), "''");
1986    }
1987
1988    #[test]
1989    fn escape_for_shell_with_single_quote() {
1990        // "Nika's" becomes "'Nika'\''s'"
1991        assert_eq!(escape_for_shell("Nika's"), "'Nika'\\''s'");
1992    }
1993
1994    #[test]
1995    fn escape_for_shell_with_multiple_quotes() {
1996        // "don't won't" should escape both quotes
1997        assert_eq!(escape_for_shell("don't won't"), "'don'\\''t won'\\''t'");
1998    }
1999
2000    #[test]
2001    fn escape_for_shell_with_special_chars() {
2002        // Special shell characters should be safe inside single quotes
2003        assert_eq!(escape_for_shell("$HOME;rm -rf /"), "'$HOME;rm -rf /'");
2004    }
2005
2006    #[test]
2007    fn escape_for_shell_with_backticks() {
2008        // Backticks should be safe inside single quotes
2009        assert_eq!(escape_for_shell("`whoami`"), "'`whoami`'");
2010    }
2011
2012    #[test]
2013    fn escape_for_shell_with_newlines() {
2014        // Newlines should be preserved inside single quotes
2015        assert_eq!(escape_for_shell("line1\nline2"), "'line1\nline2'");
2016    }
2017
2018    // ═══════════════════════════════════════════════════════════════════════════
2019    // |shell modifier tests
2020    // ═══════════════════════════════════════════════════════════════════════════
2021
2022    #[test]
2023    fn resolve_shell_modifier_simple() {
2024        let mut bindings = ResolvedBindings::new();
2025        bindings.set("msg", json!("hello world"));
2026        let ds = empty_datastore();
2027
2028        // Using |shell modifier applies shell escaping
2029        let result = resolve("echo {{with.msg|shell}}", &bindings, &ds).unwrap();
2030        assert_eq!(result, "echo 'hello world'");
2031    }
2032
2033    #[test]
2034    fn resolve_shell_modifier_with_quote() {
2035        let mut bindings = ResolvedBindings::new();
2036        bindings.set("response", json!("Hello from Nika's v0.5.1!"));
2037        let ds = empty_datastore();
2038
2039        // The |shell modifier escapes single quotes correctly
2040        let result = resolve("echo {{with.response|shell}}", &bindings, &ds).unwrap();
2041        assert_eq!(result, "echo 'Hello from Nika'\\''s v0.5.1!'");
2042    }
2043
2044    #[test]
2045    fn resolve_shell_modifier_with_special_chars() {
2046        let mut bindings = ResolvedBindings::new();
2047        bindings.set("content", json!("Hello; echo pwned"));
2048        let ds = empty_datastore();
2049
2050        // Shell special characters are safely escaped
2051        let result = resolve("echo {{with.content|shell}}", &bindings, &ds).unwrap();
2052        assert_eq!(result, "echo 'Hello; echo pwned'");
2053    }
2054
2055    #[test]
2056    fn resolve_without_modifier_no_escape() {
2057        let mut bindings = ResolvedBindings::new();
2058        bindings.set("msg", json!("hello world"));
2059        let ds = empty_datastore();
2060
2061        // Without |shell modifier, no escaping happens
2062        let result = resolve("echo {{with.msg}}", &bindings, &ds).unwrap();
2063        assert_eq!(result, "echo hello world");
2064    }
2065
2066    #[test]
2067    fn resolve_shell_modifier_multiple() {
2068        let mut bindings = ResolvedBindings::new();
2069        bindings.set("file", json!("test.txt"));
2070        bindings.set("content", json!("Hello 'world'"));
2071        let ds = empty_datastore();
2072
2073        // Multiple bindings with |shell modifier
2074        let result = resolve(
2075            "cat {{with.file|shell}} && echo {{with.content|shell}}",
2076            &bindings,
2077            &ds,
2078        )
2079        .unwrap();
2080        assert_eq!(result, "cat 'test.txt' && echo 'Hello '\\''world'\\'''");
2081    }
2082
2083    #[test]
2084    fn resolve_for_shell_simple() {
2085        let mut bindings = ResolvedBindings::new();
2086        bindings.set("msg", json!("hello world"));
2087        let ds = empty_datastore();
2088
2089        let result = resolve_for_shell("echo {{with.msg}}", &bindings, &ds).unwrap();
2090        assert_eq!(result, "echo 'hello world'");
2091    }
2092
2093    #[test]
2094    fn resolve_for_shell_with_quote() {
2095        let mut bindings = ResolvedBindings::new();
2096        bindings.set("response", json!("Hello from Nika's v0.5.1!"));
2097        let ds = empty_datastore();
2098
2099        // resolve_for_shell escapes ALL bindings
2100        let result =
2101            resolve_for_shell("echo 'Claude said: {{with.response}}'", &bindings, &ds).unwrap();
2102        // The output has escaped quotes
2103        assert_eq!(
2104            result,
2105            "echo 'Claude said: 'Hello from Nika'\\''s v0.5.1!''"
2106        );
2107    }
2108
2109    #[test]
2110    fn resolve_for_shell_no_templates() {
2111        let bindings = ResolvedBindings::new();
2112        let ds = empty_datastore();
2113
2114        // No templates - should return borrowed string
2115        let result = resolve_for_shell("echo hello", &bindings, &ds).unwrap();
2116        assert_eq!(result, "echo hello");
2117        assert!(matches!(result, Cow::Borrowed(_)));
2118    }
2119
2120    #[test]
2121    fn resolve_for_shell_preserves_command_structure() {
2122        let mut bindings = ResolvedBindings::new();
2123        bindings.set("file", json!("test.txt"));
2124        bindings.set("content", json!("Hello; echo pwned"));
2125        let ds = empty_datastore();
2126
2127        // The command structure is preserved, only the value is escaped
2128        let result =
2129            resolve_for_shell("cat {{with.file}} && echo {{with.content}}", &bindings, &ds)
2130                .unwrap();
2131        assert_eq!(result, "cat 'test.txt' && echo 'Hello; echo pwned'");
2132    }
2133
2134    // ═══════════════════════════════════════════════════════════════════════════
2135    // Input binding tests
2136    // ═══════════════════════════════════════════════════════════════════════════
2137
2138    use rustc_hash::FxHashMap;
2139
2140    /// Helper to create datastore with inputs for tests
2141    fn datastore_with_inputs() -> RunContext {
2142        let store = RunContext::new();
2143        let mut inputs = FxHashMap::default();
2144        inputs.insert(
2145            "topic".to_string(),
2146            json!({
2147                "type": "string",
2148                "default": "AI QR code generation"
2149            }),
2150        );
2151        inputs.insert(
2152            "depth".to_string(),
2153            json!({
2154                "type": "string",
2155                "default": "comprehensive"
2156            }),
2157        );
2158        inputs.insert(
2159            "config".to_string(),
2160            json!({
2161                "type": "object",
2162                "default": {
2163                    "theme": "dark",
2164                    "count": 5
2165                }
2166            }),
2167        );
2168        store.set_inputs(inputs);
2169        store
2170    }
2171
2172    #[test]
2173    fn resolve_inputs_simple() {
2174        let bindings = ResolvedBindings::new();
2175        let ds = datastore_with_inputs();
2176
2177        let result = resolve("Topic: {{inputs.topic}}", &bindings, &ds).unwrap();
2178        assert_eq!(result, "Topic: AI QR code generation");
2179    }
2180
2181    #[test]
2182    fn resolve_inputs_multiple() {
2183        let bindings = ResolvedBindings::new();
2184        let ds = datastore_with_inputs();
2185
2186        let result = resolve(
2187            "Research {{inputs.topic}} at {{inputs.depth}} depth",
2188            &bindings,
2189            &ds,
2190        )
2191        .unwrap();
2192        assert_eq!(
2193            result,
2194            "Research AI QR code generation at comprehensive depth"
2195        );
2196    }
2197
2198    #[test]
2199    fn resolve_inputs_nested() {
2200        let bindings = ResolvedBindings::new();
2201        let ds = datastore_with_inputs();
2202
2203        let result = resolve("Theme: {{inputs.config.theme}}", &bindings, &ds).unwrap();
2204        assert_eq!(result, "Theme: dark");
2205    }
2206
2207    #[test]
2208    fn resolve_inputs_with_use_bindings() {
2209        let mut bindings = ResolvedBindings::new();
2210        bindings.set("greeting", json!("Hello"));
2211        let ds = datastore_with_inputs();
2212
2213        let result = resolve(
2214            "{{with.greeting}}! Research {{inputs.topic}}",
2215            &bindings,
2216            &ds,
2217        )
2218        .unwrap();
2219        assert_eq!(result, "Hello! Research AI QR code generation");
2220    }
2221
2222    #[test]
2223    fn resolve_inputs_with_context() {
2224        let mut bindings = ResolvedBindings::new();
2225        bindings.set("msg", json!("Test"));
2226        let store = RunContext::new();
2227
2228        // Set both context and inputs
2229        let mut context = LoadedContext::new();
2230        context
2231            .files
2232            .insert("brand".to_string(), json!("QR Code AI"));
2233        store.set_context(context);
2234
2235        let mut inputs = FxHashMap::default();
2236        inputs.insert(
2237            "topic".to_string(),
2238            json!({
2239                "type": "string",
2240                "default": "AI trends"
2241            }),
2242        );
2243        store.set_inputs(inputs);
2244
2245        let result = resolve(
2246            "{{with.msg}}: {{context.files.brand}} - {{inputs.topic}}",
2247            &bindings,
2248            &store,
2249        )
2250        .unwrap();
2251        assert_eq!(result, "Test: QR Code AI - AI trends");
2252    }
2253
2254    #[test]
2255    fn resolve_inputs_not_found() {
2256        let bindings = ResolvedBindings::new();
2257        let ds = datastore_with_inputs();
2258
2259        let result = resolve("{{inputs.nonexistent}}", &bindings, &ds);
2260        assert!(result.is_err());
2261        let err = result.unwrap_err();
2262        assert!(err.to_string().contains("Input binding"));
2263        assert!(err.to_string().contains("nonexistent"));
2264    }
2265
2266    #[test]
2267    fn resolve_inputs_no_inputs_loaded() {
2268        let bindings = ResolvedBindings::new();
2269        let ds = empty_datastore(); // No inputs
2270
2271        let result = resolve("{{inputs.topic}}", &bindings, &ds);
2272        assert!(result.is_err());
2273    }
2274
2275    #[test]
2276    fn resolve_only_inputs_no_use() {
2277        let bindings = ResolvedBindings::new();
2278        let ds = datastore_with_inputs();
2279
2280        // Template with ONLY inputs bindings, no use bindings
2281        let result = resolve("Topic is {{inputs.topic}}", &bindings, &ds).unwrap();
2282        assert_eq!(result, "Topic is AI QR code generation");
2283    }
2284
2285    #[test]
2286    fn resolve_inputs_preserves_no_template() {
2287        let bindings = ResolvedBindings::new();
2288        let ds = datastore_with_inputs();
2289
2290        // No templates at all
2291        let result = resolve("Plain text without templates", &bindings, &ds).unwrap();
2292        assert_eq!(result, "Plain text without templates");
2293        // Should be borrowed (zero alloc)
2294        assert!(matches!(result, Cow::Borrowed(_)));
2295    }
2296
2297    // ═══════════════════════════════════════════════════════════════════════════
2298    // Template Injection Security Tests
2299    // ═══════════════════════════════════════════════════════════════════════════
2300    //
2301    // These tests verify that malicious content in template values cannot:
2302    // 1. Break out of the intended context (JSON, shell, etc.)
2303    // 2. Cause re-evaluation of template syntax
2304    // 3. Inject control characters or escape sequences
2305    //
2306    // Security principle: Template values are DATA, not CODE.
2307    // They should be interpolated literally, never interpreted.
2308
2309    #[test]
2310    fn injection_template_syntax_not_reevaluated() {
2311        // Value contains template syntax - should NOT be re-evaluated
2312        let mut bindings = ResolvedBindings::new();
2313        bindings.set("user_input", json!("{{with.secret}}"));
2314        bindings.set("secret", json!("TOP_SECRET"));
2315        let ds = empty_datastore();
2316
2317        let result = resolve("User said: {{with.user_input}}", &bindings, &ds).unwrap();
2318        // The {{with.secret}} should appear literally, NOT expanded recursively
2319        assert_eq!(result, "User said: {{with.secret}}");
2320        assert!(!result.contains("TOP_SECRET"));
2321    }
2322
2323    #[test]
2324    fn injection_nested_template_attack() {
2325        // Attempt to construct template syntax via concatenation
2326        let mut bindings = ResolvedBindings::new();
2327        bindings.set("left", json!("{{with."));
2328        bindings.set("right", json!("secret}}"));
2329        bindings.set("secret", json!("LEAKED"));
2330        let ds = empty_datastore();
2331
2332        // Even with split template markers, no re-evaluation should occur
2333        let result = resolve("{{with.left}}{{with.right}}", &bindings, &ds).unwrap();
2334        assert_eq!(result, "{{with.secret}}");
2335        assert!(!result.contains("LEAKED"));
2336    }
2337
2338    #[test]
2339    fn injection_json_context_quotes_escaped() {
2340        // Value with quotes in JSON context should be escaped
2341        let mut bindings = ResolvedBindings::new();
2342        bindings.set("name", json!(r#"Alice", "admin": true, "x": "#));
2343        let ds = empty_datastore();
2344
2345        // Template is in a JSON context (inside quotes)
2346        let template = r#"{"user": "{{with.name}}"}"#;
2347        let result = resolve(template, &bindings, &ds).unwrap();
2348
2349        // The quotes should be escaped with backslash
2350        assert!(
2351            result.contains(r#"\""#),
2352            "Quotes should be escaped: {}",
2353            result
2354        );
2355        // The result should be valid JSON - quotes are escaped so injection fails
2356        // The "admin" key appears but as escaped string content, not as JSON structure
2357        assert_eq!(
2358            result, r#"{"user": "Alice\", \"admin\": true, \"x\": "}"#,
2359            "Quotes should be escaped to prevent JSON structure injection"
2360        );
2361        // Verify the injected "admin" is inside the string value, not a real key
2362        // by checking the escaped pattern exists
2363        assert!(result.contains(r#"\"admin\""#), "admin should be escaped");
2364    }
2365
2366    #[test]
2367    fn injection_json_context_backslash_escaped() {
2368        // Backslashes in JSON context should be double-escaped
2369        let mut bindings = ResolvedBindings::new();
2370        bindings.set("path", json!(r#"C:\Users\admin"#));
2371        let ds = empty_datastore();
2372
2373        let template = r#"{"path": "{{with.path}}"}"#;
2374        let result = resolve(template, &bindings, &ds).unwrap();
2375
2376        // Backslashes should be escaped
2377        assert!(
2378            result.contains(r#"\\"#),
2379            "Backslashes should be escaped: {}",
2380            result
2381        );
2382    }
2383
2384    #[test]
2385    fn injection_json_context_newline_escaped() {
2386        // Newlines in JSON context should be escaped as \n
2387        let mut bindings = ResolvedBindings::new();
2388        bindings.set("text", json!("line1\nline2"));
2389        let ds = empty_datastore();
2390
2391        let template = r#"{"text": "{{with.text}}"}"#;
2392        let result = resolve(template, &bindings, &ds).unwrap();
2393
2394        // Raw newline should become \n
2395        assert!(
2396            result.contains(r#"\n"#),
2397            "Newlines should be escaped: {}",
2398            result
2399        );
2400        assert!(
2401            !result.contains('\n') || result.matches('\n').count() == 0 || result.contains("\\n"),
2402            "Raw newlines should be escaped"
2403        );
2404    }
2405
2406    #[test]
2407    fn injection_shell_modifier_escapes_semicolon() {
2408        // Semicolon injection attempt with |shell modifier
2409        let mut bindings = ResolvedBindings::new();
2410        bindings.set("filename", json!("file.txt; rm -rf /"));
2411        let ds = empty_datastore();
2412
2413        let result = resolve("cat {{with.filename|shell}}", &bindings, &ds).unwrap();
2414        // With |shell, the dangerous command is wrapped in single quotes
2415        // This makes the semicolon a literal character, not a command separator
2416        assert_eq!(result, "cat 'file.txt; rm -rf /'");
2417        // The entire value including semicolon is inside quotes - safe
2418        assert!(result.starts_with("cat '") && result.ends_with("'"));
2419    }
2420
2421    #[test]
2422    fn injection_shell_modifier_escapes_backticks() {
2423        // Command substitution injection attempt
2424        let mut bindings = ResolvedBindings::new();
2425        bindings.set("input", json!("`whoami`"));
2426        let ds = empty_datastore();
2427
2428        let result = resolve("echo {{with.input|shell}}", &bindings, &ds).unwrap();
2429        // Backticks safely quoted
2430        assert_eq!(result, "echo '`whoami`'");
2431    }
2432
2433    #[test]
2434    fn injection_shell_modifier_escapes_dollar_parens() {
2435        // $(command) injection attempt
2436        let mut bindings = ResolvedBindings::new();
2437        bindings.set("input", json!("$(cat /etc/passwd)"));
2438        let ds = empty_datastore();
2439
2440        let result = resolve("echo {{with.input|shell}}", &bindings, &ds).unwrap();
2441        // Dollar-paren safely quoted
2442        assert_eq!(result, "echo '$(cat /etc/passwd)'");
2443    }
2444
2445    #[test]
2446    fn injection_shell_modifier_escapes_env_vars() {
2447        // Environment variable injection
2448        let mut bindings = ResolvedBindings::new();
2449        bindings.set("input", json!("$HOME/.ssh/id_rsa"));
2450        let ds = empty_datastore();
2451
2452        let result = resolve("cat {{with.input|shell}}", &bindings, &ds).unwrap();
2453        // $HOME is literal, not expanded
2454        assert_eq!(result, "cat '$HOME/.ssh/id_rsa'");
2455    }
2456
2457    #[test]
2458    fn injection_resolve_for_shell_escapes_all() {
2459        // resolve_for_shell should escape ALL bindings automatically
2460        let mut bindings = ResolvedBindings::new();
2461        bindings.set("cmd", json!("echo 'pwned'; rm -rf /"));
2462        let ds = empty_datastore();
2463
2464        let result = resolve_for_shell("{{with.cmd}}", &bindings, &ds).unwrap();
2465        // The entire value is shell-escaped using single-quote escaping
2466        // The embedded single quote in 'pwned' is escaped as '\''
2467        assert_eq!(result, "'echo '\\''pwned'\\''; rm -rf /'");
2468        // The value starts and ends with single quotes, making everything inside literal
2469        // Even though '; rm' appears in the string, it's inside quoted context
2470    }
2471
2472    #[test]
2473    fn injection_control_characters_json() {
2474        // Control characters should be escaped in JSON context
2475        let mut bindings = ResolvedBindings::new();
2476        // Tab, carriage return, and form feed
2477        bindings.set("data", json!("a\tb\rc\x0c"));
2478        let ds = empty_datastore();
2479
2480        let template = r#"{"data": "{{with.data}}"}"#;
2481        let result = resolve(template, &bindings, &ds).unwrap();
2482
2483        // Control chars should be escaped
2484        assert!(result.contains(r#"\t"#) || !result.contains('\t'));
2485        assert!(result.contains(r#"\r"#) || !result.contains('\r'));
2486    }
2487
2488    #[test]
2489    fn injection_unicode_escape_sequences() {
2490        // Unicode escape sequences should be treated literally
2491        let mut bindings = ResolvedBindings::new();
2492        bindings.set("text", json!(r#"\u0000"#)); // Literal backslash-u
2493        let ds = empty_datastore();
2494
2495        let result = resolve("Text: {{with.text}}", &bindings, &ds).unwrap();
2496        // Should appear as literal \u0000, not null byte
2497        assert_eq!(result, r#"Text: \u0000"#);
2498    }
2499
2500    #[test]
2501    fn injection_null_byte_in_value() {
2502        // JSON Value::String cannot contain null bytes (serde_json rejects them)
2503        // But if somehow present, should be handled safely
2504        let mut bindings = ResolvedBindings::new();
2505        // serde_json from string with null - this is actually impossible via json!
2506        // but we test the principle
2507        bindings.set("normal", json!("safe"));
2508        let ds = empty_datastore();
2509
2510        let result = resolve("{{with.normal}}", &bindings, &ds).unwrap();
2511        assert_eq!(result, "safe");
2512    }
2513
2514    #[test]
2515    fn injection_very_long_value() {
2516        // Very long values should be handled without stack overflow
2517        let mut bindings = ResolvedBindings::new();
2518        let long_string = "A".repeat(100_000);
2519        bindings.set("big", json!(long_string.clone()));
2520        let ds = empty_datastore();
2521
2522        let result = resolve("Data: {{with.big}}", &bindings, &ds).unwrap();
2523        assert!(result.starts_with("Data: AAAA"));
2524        assert_eq!(result.len(), 6 + 100_000); // "Data: " + 100k As
2525    }
2526
2527    #[test]
2528    fn injection_deeply_nested_json_value() {
2529        // Deeply nested JSON should serialize correctly
2530        let mut bindings = ResolvedBindings::new();
2531        bindings.set("nested", json!({"a": {"b": {"c": {"d": "deep"}}}}));
2532        let ds = empty_datastore();
2533
2534        let result = resolve("{{with.nested}}", &bindings, &ds).unwrap();
2535        // Should be serialized JSON, not crash
2536        assert!(result.contains("deep"));
2537    }
2538
2539    #[test]
2540    fn injection_template_markers_in_context_path() {
2541        // Even context paths with template-like patterns should be safe
2542        let bindings = ResolvedBindings::new();
2543        let store = RunContext::new();
2544
2545        let mut context = LoadedContext::new();
2546        // File name that looks like template syntax - but file content is safe
2547        context
2548            .files
2549            .insert("normal".to_string(), json!("safe content"));
2550        store.set_context(context);
2551
2552        let result = resolve("{{context.files.normal}}", &bindings, &store).unwrap();
2553        assert_eq!(result, "safe content");
2554    }
2555
2556    #[test]
2557    fn injection_context_value_with_template_syntax() {
2558        // Context file content with template syntax should NOT be re-evaluated
2559        let bindings = ResolvedBindings::new();
2560        let store = RunContext::new();
2561
2562        let mut context = LoadedContext::new();
2563        context
2564            .files
2565            .insert("brand".to_string(), json!("Brand: {{with.secret}}"));
2566        store.set_context(context);
2567
2568        let result = resolve("{{context.files.brand}}", &bindings, &store).unwrap();
2569        // Template syntax in the VALUE should appear literally
2570        assert_eq!(result, "Brand: {{with.secret}}");
2571    }
2572
2573    #[test]
2574    fn injection_input_value_with_template_syntax() {
2575        // Input values with template syntax should NOT be re-evaluated
2576        let bindings = ResolvedBindings::new();
2577        let store = RunContext::new();
2578
2579        let mut inputs = FxHashMap::default();
2580        inputs.insert(
2581            "topic".to_string(),
2582            json!({
2583                "type": "string",
2584                "default": "Learn about {{with.secret}}"
2585            }),
2586        );
2587        store.set_inputs(inputs);
2588
2589        let result = resolve("{{inputs.topic}}", &bindings, &store).unwrap();
2590        // Template syntax in input default should appear literally
2591        assert_eq!(result, "Learn about {{with.secret}}");
2592    }
2593
2594    #[test]
2595    fn injection_3pass_no_cross_contamination() {
2596        // Verify 3-pass resolution doesn't allow pass N output to affect pass N+1
2597        let mut bindings = ResolvedBindings::new();
2598        // Pass 1: use binding resolves to something with context syntax
2599        bindings.set("data", json!("{{context.files.secret}}"));
2600        let store = RunContext::new();
2601
2602        let mut context = LoadedContext::new();
2603        context
2604            .files
2605            .insert("secret".to_string(), json!("CONFIDENTIAL"));
2606        store.set_context(context);
2607
2608        // Template only has use binding, but its value contains context syntax
2609        let result = resolve("Result: {{with.data}}", &bindings, &store).unwrap();
2610        // The context syntax should NOT be evaluated in pass 2
2611        assert_eq!(result, "Result: {{context.files.secret}}");
2612        assert!(!result.contains("CONFIDENTIAL"));
2613    }
2614
2615    #[test]
2616    fn injection_html_script_tags() {
2617        // HTML script injection - template just passes through
2618        let mut bindings = ResolvedBindings::new();
2619        bindings.set("content", json!("<script>alert('xss')</script>"));
2620        let ds = empty_datastore();
2621
2622        let result = resolve("{{with.content}}", &bindings, &ds).unwrap();
2623        // NOTE: Template resolution does NOT escape HTML - that's the consumer's job
2624        // This test documents the behavior: raw HTML passes through
2625        assert_eq!(result, "<script>alert('xss')</script>");
2626    }
2627
2628    #[test]
2629    fn injection_sql_like_content() {
2630        // SQL-like content - template just passes through
2631        let mut bindings = ResolvedBindings::new();
2632        bindings.set("query", json!("'; DROP TABLE users; --"));
2633        let ds = empty_datastore();
2634
2635        let result = resolve(
2636            "SELECT * FROM x WHERE name='{{with.query}}'",
2637            &bindings,
2638            &ds,
2639        )
2640        .unwrap();
2641        // Template resolution does NOT prevent SQL injection - that's the DB layer's job
2642        // This test documents the behavior
2643        assert!(result.contains("DROP TABLE"));
2644    }
2645}
2646
2647// ═════════════════════════════════════════════════════════════════════════════
2648// New template engine tests — parse_template_expr + resolve_with
2649// ═════════════════════════════════════════════════════════════════════════════
2650
2651#[cfg(test)]
2652mod v028_template_tests {
2653    use super::*;
2654    use crate::store::{LoadedContext, RunContext};
2655    use serde_json::json;
2656
2657    fn empty_datastore() -> RunContext {
2658        RunContext::new()
2659    }
2660
2661    fn make_with(entries: &[(&str, Value)]) -> FxHashMap<String, Value> {
2662        entries
2663            .iter()
2664            .map(|(k, v)| (k.to_string(), v.clone()))
2665            .collect()
2666    }
2667
2668    // ─── parse_template_expr tests ───────────────────────────────────────────
2669
2670    #[test]
2671    fn parse_expr_simple_alias() {
2672        let result = parse_template_expr("title").unwrap();
2673        assert_eq!(
2674            result,
2675            TemplateExpr::Alias {
2676                path: "title".to_string(),
2677                transforms: vec![],
2678            }
2679        );
2680    }
2681
2682    #[test]
2683    fn parse_expr_alias_with_path() {
2684        let result = parse_template_expr("data.items").unwrap();
2685        assert_eq!(
2686            result,
2687            TemplateExpr::Alias {
2688                path: "data.items".to_string(),
2689                transforms: vec![],
2690            }
2691        );
2692    }
2693
2694    #[test]
2695    fn parse_expr_alias_single_transform() {
2696        let result = parse_template_expr("title | upper").unwrap();
2697        assert_eq!(
2698            result,
2699            TemplateExpr::Alias {
2700                path: "title".to_string(),
2701                transforms: vec!["upper".to_string()],
2702            }
2703        );
2704    }
2705
2706    #[test]
2707    fn parse_expr_alias_multi_transform() {
2708        let result = parse_template_expr("x | sort | unique | first(3)").unwrap();
2709        assert_eq!(
2710            result,
2711            TemplateExpr::Alias {
2712                path: "x".to_string(),
2713                transforms: vec![
2714                    "sort".to_string(),
2715                    "unique".to_string(),
2716                    "first(3)".to_string(),
2717                ],
2718            }
2719        );
2720    }
2721
2722    #[test]
2723    fn parse_expr_context_files() {
2724        let result = parse_template_expr("context.files.brand").unwrap();
2725        assert_eq!(
2726            result,
2727            TemplateExpr::Context {
2728                path: "files.brand".to_string(),
2729                transforms: vec![]
2730            }
2731        );
2732    }
2733
2734    #[test]
2735    fn parse_expr_context_session() {
2736        let result = parse_template_expr("context.session.key").unwrap();
2737        assert_eq!(
2738            result,
2739            TemplateExpr::Context {
2740                path: "session.key".to_string(),
2741                transforms: vec![]
2742            }
2743        );
2744    }
2745
2746    #[test]
2747    fn parse_expr_inputs() {
2748        let result = parse_template_expr("inputs.locale").unwrap();
2749        assert_eq!(
2750            result,
2751            TemplateExpr::Input {
2752                path: "locale".to_string(),
2753                transforms: vec![]
2754            }
2755        );
2756    }
2757
2758    #[test]
2759    fn parse_expr_inputs_nested() {
2760        let result = parse_template_expr("inputs.config.theme").unwrap();
2761        assert_eq!(
2762            result,
2763            TemplateExpr::Input {
2764                path: "config.theme".to_string(),
2765                transforms: vec![]
2766            }
2767        );
2768    }
2769
2770    #[test]
2771    fn parse_expr_context_with_transforms() {
2772        let result = parse_template_expr("context.files.brand | upper").unwrap();
2773        assert_eq!(
2774            result,
2775            TemplateExpr::Context {
2776                path: "files.brand".to_string(),
2777                transforms: vec!["upper".to_string()]
2778            }
2779        );
2780    }
2781
2782    #[test]
2783    fn parse_expr_inputs_with_transforms() {
2784        let result = parse_template_expr("inputs.topic | lower | trim").unwrap();
2785        assert_eq!(
2786            result,
2787            TemplateExpr::Input {
2788                path: "topic".to_string(),
2789                transforms: vec!["lower".to_string(), "trim".to_string()]
2790            }
2791        );
2792    }
2793
2794    #[test]
2795    fn parse_expr_contextual_is_alias() {
2796        // "contextual" should NOT match "context." prefix
2797        let result = parse_template_expr("contextual").unwrap();
2798        assert_eq!(
2799            result,
2800            TemplateExpr::Alias {
2801                path: "contextual".to_string(),
2802                transforms: vec![],
2803            }
2804        );
2805    }
2806
2807    #[test]
2808    fn parse_expr_inputstream_is_alias() {
2809        // "inputstream" should NOT match "inputs." prefix
2810        let result = parse_template_expr("inputstream").unwrap();
2811        assert_eq!(
2812            result,
2813            TemplateExpr::Alias {
2814                path: "inputstream".to_string(),
2815                transforms: vec![],
2816            }
2817        );
2818    }
2819
2820    #[test]
2821    fn parse_expr_empty_is_error() {
2822        let result = parse_template_expr("");
2823        assert!(result.is_err());
2824    }
2825
2826    #[test]
2827    fn parse_expr_whitespace_is_error() {
2828        let result = parse_template_expr("   ");
2829        assert!(result.is_err());
2830    }
2831
2832    #[test]
2833    fn parse_expr_context_dot_only_is_error() {
2834        let result = parse_template_expr("context.");
2835        assert!(result.is_err());
2836    }
2837
2838    #[test]
2839    fn parse_expr_inputs_dot_only_is_error() {
2840        let result = parse_template_expr("inputs.");
2841        assert!(result.is_err());
2842    }
2843
2844    #[test]
2845    fn parse_expr_whitespace_trimmed() {
2846        let result = parse_template_expr("  title  ").unwrap();
2847        assert_eq!(
2848            result,
2849            TemplateExpr::Alias {
2850                path: "title".to_string(),
2851                transforms: vec![],
2852            }
2853        );
2854    }
2855
2856    #[test]
2857    fn parse_expr_transform_with_spaces() {
2858        let result = parse_template_expr("  name  |  upper  |  trim  ").unwrap();
2859        assert_eq!(
2860            result,
2861            TemplateExpr::Alias {
2862                path: "name".to_string(),
2863                transforms: vec!["upper".to_string(), "trim".to_string()],
2864            }
2865        );
2866    }
2867
2868    // ─── value_to_display tests ──────────────────────────────────────────────
2869
2870    #[test]
2871    fn display_string() {
2872        assert_eq!(value_to_display(&json!("hello")), "hello");
2873    }
2874
2875    #[test]
2876    fn display_number() {
2877        assert_eq!(value_to_display(&json!(42)), "42");
2878        assert_eq!(value_to_display(&json!(3.12)), "3.12");
2879    }
2880
2881    #[test]
2882    fn display_bool() {
2883        assert_eq!(value_to_display(&json!(true)), "true");
2884        assert_eq!(value_to_display(&json!(false)), "false");
2885    }
2886
2887    #[test]
2888    fn display_null_is_empty() {
2889        assert_eq!(value_to_display(&Value::Null), "");
2890    }
2891
2892    #[test]
2893    fn display_array() {
2894        assert_eq!(value_to_display(&json!([1, 2, 3])), "[1,2,3]");
2895    }
2896
2897    #[test]
2898    fn display_object() {
2899        let val = json!({"a": 1});
2900        let display = value_to_display(&val);
2901        assert!(display.contains("\"a\""));
2902        assert!(display.contains("1"));
2903    }
2904
2905    // ─── resolve_with tests (Pass 1: alias resolution) ──────────────────────
2906
2907    #[test]
2908    fn resolve_with_simple_alias() {
2909        let with = make_with(&[("name", json!("World"))]);
2910        let ds = empty_datastore();
2911        let result = resolve_with("Hello {{name}}", &with, &ds).unwrap();
2912        assert_eq!(result, "Hello World");
2913    }
2914
2915    #[test]
2916    fn resolve_with_deep_alias() {
2917        let with = make_with(&[("data", json!({"items": [1, 2, 3]}))]);
2918        let ds = empty_datastore();
2919        let result = resolve_with("Items: {{data.items}}", &with, &ds).unwrap();
2920        assert_eq!(result, "Items: [1,2,3]");
2921    }
2922
2923    #[test]
2924    fn resolve_with_transform() {
2925        let with = make_with(&[("title", json!("hello world"))]);
2926        let ds = empty_datastore();
2927        let result = resolve_with("{{title | upper}}", &with, &ds).unwrap();
2928        assert_eq!(result, "HELLO WORLD");
2929    }
2930
2931    #[test]
2932    fn resolve_with_array_json_serialization() {
2933        let with = make_with(&[("items", json!(["a", "b", "c"]))]);
2934        let ds = empty_datastore();
2935        let result = resolve_with("{{items}}", &with, &ds).unwrap();
2936        assert_eq!(result, "[\"a\",\"b\",\"c\"]");
2937    }
2938
2939    #[test]
2940    fn resolve_with_null_is_empty() {
2941        let with = make_with(&[("val", Value::Null)]);
2942        let ds = empty_datastore();
2943        let result = resolve_with("Got: {{val}}!", &with, &ds).unwrap();
2944        assert_eq!(result, "Got: !");
2945    }
2946
2947    #[test]
2948    fn resolve_with_multiple_aliases() {
2949        let with = make_with(&[("a", json!("hello")), ("b", json!("world"))]);
2950        let ds = empty_datastore();
2951        let result = resolve_with("{{a}} and {{b}}", &with, &ds).unwrap();
2952        assert_eq!(result, "hello and world");
2953    }
2954
2955    #[test]
2956    fn resolve_with_missing_alias_errors() {
2957        let with = make_with(&[("name", json!("Alice"))]);
2958        let ds = empty_datastore();
2959        let result = resolve_with("{{missing}}", &with, &ds);
2960        assert!(result.is_err());
2961    }
2962
2963    #[test]
2964    fn resolve_with_number() {
2965        let with = make_with(&[("count", json!(42))]);
2966        let ds = empty_datastore();
2967        let result = resolve_with("Count: {{count}}", &with, &ds).unwrap();
2968        assert_eq!(result, "Count: 42");
2969    }
2970
2971    #[test]
2972    fn resolve_with_bool() {
2973        let with = make_with(&[("flag", json!(true))]);
2974        let ds = empty_datastore();
2975        let result = resolve_with("Flag: {{flag}}", &with, &ds).unwrap();
2976        assert_eq!(result, "Flag: true");
2977    }
2978
2979    // ─── resolve_with tests (Pass 2: context + inputs) ──────────────────────
2980
2981    #[test]
2982    fn resolve_with_context_file() {
2983        let with = FxHashMap::default();
2984        let ds = empty_datastore();
2985        let mut context = LoadedContext::new();
2986        context
2987            .files
2988            .insert("brand".to_string(), json!("SuperNovae AI"));
2989        ds.set_context(context);
2990
2991        let result = resolve_with("Brand: {{context.files.brand}}", &with, &ds).unwrap();
2992        assert_eq!(result, "Brand: SuperNovae AI");
2993    }
2994
2995    #[test]
2996    fn resolve_with_context_session() {
2997        let with = FxHashMap::default();
2998        let ds = empty_datastore();
2999        let mut context = LoadedContext::new();
3000        context.session = Some(json!({"focus": "rust"}));
3001        ds.set_context(context);
3002
3003        let result = resolve_with("Focus: {{context.session.focus}}", &with, &ds).unwrap();
3004        assert_eq!(result, "Focus: rust");
3005    }
3006
3007    #[test]
3008    fn resolve_with_inputs() {
3009        let with = FxHashMap::default();
3010        let ds = empty_datastore();
3011        let mut inputs = FxHashMap::default();
3012        inputs.insert("locale".to_string(), json!("fr-FR"));
3013        ds.set_inputs(inputs);
3014
3015        let result = resolve_with("Locale: {{inputs.locale}}", &with, &ds).unwrap();
3016        assert_eq!(result, "Locale: fr-FR");
3017    }
3018
3019    #[test]
3020    fn resolve_with_inputs_nested() {
3021        let with = FxHashMap::default();
3022        let ds = empty_datastore();
3023        let mut inputs = FxHashMap::default();
3024        inputs.insert("config".to_string(), json!({"theme": "dark"}));
3025        ds.set_inputs(inputs);
3026
3027        let result = resolve_with("Theme: {{inputs.config.theme}}", &with, &ds).unwrap();
3028        assert_eq!(result, "Theme: dark");
3029    }
3030
3031    // ─── Security: no re-evaluation ─────────────────────────────────────────
3032
3033    #[test]
3034    fn no_reevaluation_alias_containing_template() {
3035        // If an alias value contains {{ }}, it should NOT be re-evaluated
3036        let with = make_with(&[("val", json!("{{context.files.secret}}"))]);
3037        let ds = empty_datastore();
3038        let mut context = LoadedContext::new();
3039        context
3040            .files
3041            .insert("secret".to_string(), json!("TOP_SECRET"));
3042        ds.set_context(context);
3043
3044        let result = resolve_with("Got: {{val}}", &with, &ds).unwrap();
3045        // SECURITY FIX (Bug 45): has_context/has_inputs checks now use the
3046        // ORIGINAL template, not the post-Pass-1 result. Since "Got: {{val}}"
3047        // does NOT contain "context.", Pass 2 is skipped entirely.
3048        // The {{context.files.secret}} from the alias value remains literal.
3049        assert_eq!(result, "Got: {{context.files.secret}}");
3050        assert!(!result.contains("TOP_SECRET"));
3051    }
3052
3053    #[test]
3054    fn no_reevaluation_alias_to_alias() {
3055        // If an alias value contains {{other_alias}}, it should NOT cause
3056        // another pass 1 resolution (that's single-pass for aliases)
3057        let with = make_with(&[("a", json!("{{b}}")), ("b", json!("secret"))]);
3058        let ds = empty_datastore();
3059
3060        let result = resolve_with("Got: {{a}}", &with, &ds).unwrap();
3061        // {{a}} resolves to literal "{{b}}", which is NOT re-evaluated by pass 1
3062        // Pass 2 only handles context.*/inputs.* — "b" is neither, so stays literal
3063        assert_eq!(result, "Got: {{b}}");
3064    }
3065
3066    // ─── Shell escape ────────────────────────────────────────────────────────
3067
3068    #[test]
3069    fn resolve_with_shell_escape() {
3070        let with = make_with(&[("val", json!("hello 'world'"))]);
3071        let ds = empty_datastore();
3072        let result = resolve_with("{{val | shell}}", &with, &ds).unwrap();
3073        assert_eq!(result, "'hello '\\''world'\\'''");
3074    }
3075
3076    #[test]
3077    fn resolve_with_shell_plus_transform() {
3078        let with = make_with(&[("val", json!("Hello World"))]);
3079        let ds = empty_datastore();
3080        let result = resolve_with("{{val | lower | shell}}", &with, &ds).unwrap();
3081        assert_eq!(result, "'hello world'");
3082    }
3083
3084    // ─── Edge cases ─────────────────────────────────────────────────────────
3085
3086    #[test]
3087    fn resolve_with_empty_template() {
3088        let with = FxHashMap::default();
3089        let ds = empty_datastore();
3090        let result = resolve_with("", &with, &ds).unwrap();
3091        assert_eq!(result, "");
3092    }
3093
3094    #[test]
3095    fn resolve_with_no_templates() {
3096        let with = FxHashMap::default();
3097        let ds = empty_datastore();
3098        let result = resolve_with("plain text", &with, &ds).unwrap();
3099        assert_eq!(result, "plain text");
3100        // Should be Cow::Borrowed (zero alloc)
3101        assert!(matches!(result, Cow::Borrowed(_)));
3102    }
3103
3104    #[test]
3105    fn resolve_with_unclosed_braces() {
3106        let with = FxHashMap::default();
3107        let ds = empty_datastore();
3108        // Unclosed {{ should be left as literal (TEMPLATE_RE won't match)
3109        let result = resolve_with("{{incomplete", &with, &ds).unwrap();
3110        assert_eq!(result, "{{incomplete");
3111    }
3112
3113    #[test]
3114    fn resolve_with_bracket_notation() {
3115        let with = make_with(&[("items", json!(["a", "b", "c"]))]);
3116        let ds = empty_datastore();
3117        let result = resolve_with("{{items[1]}}", &with, &ds).unwrap();
3118        assert_eq!(result, "b");
3119    }
3120
3121    #[test]
3122    fn resolve_with_nested_path() {
3123        let with = make_with(&[(
3124            "user",
3125            json!({"name": "Alice", "address": {"city": "Paris"}}),
3126        )]);
3127        let ds = empty_datastore();
3128        let result = resolve_with("{{user.address.city}}", &with, &ds).unwrap();
3129        assert_eq!(result, "Paris");
3130    }
3131
3132    #[test]
3133    fn resolve_with_mixed_aliases_and_context() {
3134        let with = make_with(&[("name", json!("Alice"))]);
3135        let ds = empty_datastore();
3136        let mut context = LoadedContext::new();
3137        context
3138            .files
3139            .insert("brand".to_string(), json!("SuperNovae"));
3140        ds.set_context(context);
3141
3142        let result =
3143            resolve_with("Hello {{name}} from {{context.files.brand}}", &with, &ds).unwrap();
3144        assert_eq!(result, "Hello Alice from SuperNovae");
3145    }
3146
3147    // ─── resource exhaustion guard tests ─────────────────────────────────────
3148
3149    #[test]
3150    fn resolve_with_rejects_excessive_template_vars() {
3151        let with = make_with(&[("x", json!("v"))]);
3152        let ds = empty_datastore();
3153        // Build a template with MAX_TEMPLATE_VARS + 1 references
3154        let template: String = (0..=MAX_TEMPLATE_VARS)
3155            .map(|_| "{{x}}")
3156            .collect::<Vec<_>>()
3157            .join(" ");
3158        let result = resolve_with(&template, &with, &ds);
3159        assert!(result.is_err());
3160        let err = result.unwrap_err();
3161        assert!(
3162            format!("{}", err).contains("exceeding the maximum"),
3163            "Expected max vars error, got: {}",
3164            err
3165        );
3166    }
3167
3168    #[test]
3169    fn resolve_with_accepts_many_vars_under_limit() {
3170        let with = make_with(&[("x", json!("v"))]);
3171        let ds = empty_datastore();
3172        // Just under the limit should succeed
3173        let template: String = (0..MAX_TEMPLATE_VARS)
3174            .map(|_| "{{x}}")
3175            .collect::<Vec<_>>()
3176            .join(" ");
3177        let result = resolve_with(&template, &with, &ds);
3178        assert!(result.is_ok());
3179    }
3180
3181    #[test]
3182    fn resolve_alias_rejects_excessive_path_depth() {
3183        // Test the resolve_alias_path guard directly via resolve_with.
3184        // Build a deep path that exceeds MAX_PATH_DEPTH segments.
3185        let segments: Vec<String> = (0..=MAX_PATH_DEPTH).map(|i| format!("k{}", i)).collect();
3186        let deep_path = segments.join(".");
3187
3188        // Build a deeply nested JSON value using serde_json::Map
3189        let mut value: Value = json!("leaf");
3190        for key in segments.iter().rev().skip(1) {
3191            let mut map = serde_json::Map::new();
3192            map.insert(key.clone(), value);
3193            value = Value::Object(map);
3194        }
3195        let with = make_with(&[(segments[0].as_str(), value)]);
3196        let ds = empty_datastore();
3197        let template = format!("{{{{{}}}}}", deep_path);
3198        let result = resolve_with(&template, &with, &ds);
3199        assert!(result.is_err());
3200        let err = result.unwrap_err();
3201        assert!(
3202            format!("{}", err).contains("exceeds maximum"),
3203            "Expected path depth error, got: {}",
3204            err
3205        );
3206    }
3207
3208    // ─── extract_with_refs tests ────────────────────────────────────────────
3209
3210    #[test]
3211    fn extract_refs_simple() {
3212        let refs = extract_with_refs("Hello {{name}}!");
3213        assert_eq!(refs, vec!["name".to_string()]);
3214    }
3215
3216    #[test]
3217    fn extract_refs_deep_path() {
3218        let refs = extract_with_refs("{{data.items.0}}");
3219        assert_eq!(refs, vec!["data".to_string()]);
3220    }
3221
3222    #[test]
3223    fn extract_refs_with_transforms() {
3224        let refs = extract_with_refs("{{title | upper | trim}}");
3225        assert_eq!(refs, vec!["title".to_string()]);
3226    }
3227
3228    #[test]
3229    fn extract_refs_skips_context_and_inputs() {
3230        let refs = extract_with_refs("{{name}} and {{context.files.brand}} and {{inputs.locale}}");
3231        assert_eq!(refs, vec!["name".to_string()]);
3232    }
3233
3234    #[test]
3235    fn extract_refs_empty() {
3236        let refs = extract_with_refs("no templates here");
3237        assert!(refs.is_empty());
3238    }
3239
3240    #[test]
3241    fn extract_refs_multiple() {
3242        let refs = extract_with_refs("{{a}} then {{b.field}} then {{c}}");
3243        assert_eq!(
3244            refs,
3245            vec!["a".to_string(), "b".to_string(), "c".to_string()]
3246        );
3247    }
3248
3249    // ─── validate_with_refs tests ───────────────────────────────────────────
3250
3251    #[test]
3252    fn validate_refs_all_declared() {
3253        let declared: FxHashSet<String> = ["name", "title"].iter().map(|s| s.to_string()).collect();
3254        let result = validate_with_refs("{{name}} and {{title}}", &declared, "task1");
3255        assert!(result.is_ok());
3256    }
3257
3258    #[test]
3259    fn validate_refs_unknown_alias() {
3260        let declared: FxHashSet<String> = ["name"].iter().map(|s| s.to_string()).collect();
3261        let result = validate_with_refs("{{name}} and {{missing}}", &declared, "task1");
3262        assert!(result.is_err());
3263    }
3264
3265    #[test]
3266    fn validate_refs_context_not_checked() {
3267        // context.* refs should not be validated against declared aliases
3268        let declared: FxHashSet<String> = FxHashSet::default();
3269        let result = validate_with_refs("{{context.files.brand}}", &declared, "task1");
3270        assert!(result.is_ok());
3271    }
3272
3273    #[test]
3274    fn validate_refs_inputs_not_checked() {
3275        // inputs.* refs should not be validated against declared aliases
3276        let declared: FxHashSet<String> = FxHashSet::default();
3277        let result = validate_with_refs("{{inputs.locale}}", &declared, "task1");
3278        assert!(result.is_ok());
3279    }
3280
3281    // ═════════════════════════════════════════════════════════════════════════
3282    // DEEP AUDIT: is_in_json_context heuristic
3283    // ═════════════════════════════════════════════════════════════════════════
3284
3285    /// AUDIT: is_in_json_context false positive with unbalanced quotes.
3286    ///
3287    /// BUG: The heuristic counts double-quote characters before the
3288    /// template position. Regular text with an odd number of quotes
3289    /// before a template triggers JSON escaping unnecessarily.
3290    #[test]
3291    fn audit_is_in_json_context_false_positive_unbalanced() {
3292        let mut bindings = ResolvedBindings::new();
3293        bindings.set("msg", json!("line1\nline2"));
3294        let ds = empty_datastore();
3295
3296        // Template with an unmatched quote before the placeholder.
3297        // Not a JSON structure at all — just natural language with a quote.
3298        let template = r#"He said "hello {{with.msg}}"#;
3299        let result = resolve(template, &bindings, &ds).unwrap();
3300
3301        // If is_in_json_context triggers, the newline is escaped to literal \n.
3302        // If it does NOT trigger, the raw newline is preserved.
3303        let has_escaped_newline = result.contains("\\n");
3304        let has_raw_newline = result.contains('\n');
3305
3306        if has_escaped_newline && !has_raw_newline {
3307            // BUG CONFIRMED: The heuristic saw an odd number of quotes
3308            // and assumed JSON context. The newline was escaped when it
3309            // should not have been.
3310            //
3311            // Impact: LLM prompts like `He said "hello {{with.msg}}"` get
3312            // values incorrectly JSON-escaped (newlines become literal \n,
3313            // backslashes get doubled, etc.).
3314            //
3315            // Fix: Use a proper JSON parser or require an explicit |json
3316            // modifier instead of auto-detecting based on quote parity.
3317            panic!(
3318                "GAP CONFIRMED: is_in_json_context false positive! \
3319                 Non-JSON template '{}' has value JSON-escaped. \
3320                 Result: '{}'",
3321                template, result
3322            );
3323        }
3324        // If raw newline preserved, the heuristic was correct here.
3325        assert!(
3326            has_raw_newline,
3327            "Newline should be preserved (not JSON-escaped): '{}'",
3328            result
3329        );
3330    }
3331
3332    /// AUDIT: is_in_json_context correct detection for actual JSON.
3333    #[test]
3334    fn audit_is_in_json_context_correct_for_real_json() {
3335        let mut bindings = ResolvedBindings::new();
3336        bindings.set("name", json!("line1\nline2"));
3337        let ds = empty_datastore();
3338
3339        let template = r#"{"user": "{{with.name}}"}"#;
3340        let result = resolve(template, &bindings, &ds).unwrap();
3341
3342        // In actual JSON context, newlines should be escaped
3343        assert!(
3344            result.contains("\\n"),
3345            "Newline should be JSON-escaped in JSON context: '{}'",
3346            result
3347        );
3348        assert!(
3349            !result.contains('\n'),
3350            "Raw newline must not appear in JSON context: '{}'",
3351            result
3352        );
3353    }
3354
3355    /// AUDIT: is_in_json_context with balanced quotes (even count).
3356    #[test]
3357    fn audit_is_in_json_context_balanced_quotes_outside() {
3358        let mut bindings = ResolvedBindings::new();
3359        bindings.set("val", json!("test\nvalue"));
3360        let ds = empty_datastore();
3361
3362        // Two balanced quotes BEFORE the template — even count = NOT in JSON
3363        let template = r#"He said "hi" then {{with.val}}"#;
3364        let result = resolve(template, &bindings, &ds).unwrap();
3365
3366        // With balanced quotes, template is outside JSON string
3367        // The newline should be raw, not escaped
3368        assert!(
3369            result.contains('\n'),
3370            "Outside JSON context, newline should be raw: '{}'",
3371            result
3372        );
3373    }
3374
3375    // ═════════════════════════════════════════════════════════════════════════
3376    // DEEP AUDIT: resolve_for_shell missing inputs support
3377    // ═════════════════════════════════════════════════════════════════════════
3378
3379    /// AUDIT: resolve_for_shell does NOT resolve {{inputs.X}} templates.
3380    ///
3381    /// BUG: resolve_for_shell checks for has_with and has_context but
3382    /// does NOT check has_inputs. Templates with {{inputs.param}} are
3383    /// silently left unresolved.
3384    #[test]
3385    fn audit_resolve_for_shell_missing_inputs_support() {
3386        let bindings = ResolvedBindings::new();
3387        let store = RunContext::new();
3388
3389        let mut inputs = FxHashMap::default();
3390        inputs.insert("topic".to_string(), json!("AI safety"));
3391        store.set_inputs(inputs);
3392
3393        let result = resolve_for_shell("echo {{inputs.topic}}", &bindings, &store).unwrap();
3394
3395        // BUG: resolve_for_shell does not support {{inputs.*}}
3396        // It checks has_with and has_context but NOT has_inputs.
3397        // The template is left unresolved.
3398        if result.contains("{{inputs.topic}}") {
3399            // GAP CONFIRMED: inputs not resolved
3400            panic!(
3401                "GAP CONFIRMED: resolve_for_shell does not resolve \
3402                 inputs templates. Result: '{}'. Fix: add has_inputs \
3403                 check alongside has_with and has_context.",
3404                result
3405            );
3406        }
3407        // If it IS resolved, the bug has been fixed
3408        assert!(result.contains("AI safety"));
3409    }
3410
3411    // ═════════════════════════════════════════════════════════════════════════
3412    // DEEP AUDIT: normalize_bracket_notation edge cases
3413    // ═════════════════════════════════════════════════════════════════════════
3414
3415    /// AUDIT: bracket notation with negative index.
3416    #[test]
3417    fn audit_bracket_notation_negative_index() {
3418        let mut bindings = ResolvedBindings::new();
3419        bindings.set("items", json!(["a", "b", "c"]));
3420        let ds = empty_datastore();
3421
3422        // Negative index is NOT supported — the template engine now correctly reports
3423        // an error instead of silently leaving the template unresolved.
3424        let result = resolve("{{with.items[-1]}}", &bindings, &ds);
3425        assert!(
3426            result.is_err(),
3427            "Negative index should produce an error, got: {:?}",
3428            result
3429        );
3430    }
3431
3432    /// AUDIT: bracket notation with non-numeric index is not supported.
3433    /// normalize_bracket_notation only converts numeric `[N]` to `.N`.
3434    /// Non-numeric keys like `[key]` are left as-is, causing resolution failure.
3435    #[test]
3436    fn audit_bracket_notation_non_numeric() {
3437        let mut bindings = ResolvedBindings::new();
3438        bindings.set("data", json!({"key": "value"}));
3439        let ds = empty_datastore();
3440
3441        // Documented limitation: bracket notation only supports numeric indices.
3442        // The template engine now correctly reports an error for non-numeric brackets.
3443        let result = resolve("{{with.data[key]}}", &bindings, &ds);
3444        assert!(
3445            result.is_err(),
3446            "Non-numeric bracket access should produce an error, got: {:?}",
3447            result
3448        );
3449    }
3450
3451    /// AUDIT: bracket notation at root level.
3452    #[test]
3453    fn audit_bracket_notation_root_array() {
3454        let mut bindings = ResolvedBindings::new();
3455        bindings.set("list", json!(["first", "second", "third"]));
3456        let ds = empty_datastore();
3457
3458        let result = resolve("{{with.list[2]}}", &bindings, &ds).unwrap();
3459        assert_eq!(result, "third");
3460    }
3461
3462    /// AUDIT: multiple bracket notations in same path.
3463    #[test]
3464    fn audit_bracket_notation_nested_arrays() {
3465        let mut bindings = ResolvedBindings::new();
3466        bindings.set("matrix", json!([[1, 2], [3, 4]]));
3467        let ds = empty_datastore();
3468
3469        let result = resolve("{{with.matrix[1][0]}}", &bindings, &ds).unwrap();
3470        assert_eq!(result, "3");
3471    }
3472
3473    // ═════════════════════════════════════════════════════════════════════════
3474    // DEEP AUDIT: resolve_with Shell transform double-application
3475    // ═════════════════════════════════════════════════════════════════════════
3476
3477    /// AUDIT: verify Shell transform and escape_for_shell produce same result.
3478    ///
3479    /// In resolve_with(), when transforms contain "shell":
3480    /// 1. TransformExpr("shell").apply() is called (result discarded)
3481    /// 2. escape_for_shell() is called separately (result used)
3482    ///
3483    /// Both SHOULD produce identical output. This test verifies that.
3484    #[test]
3485    fn audit_shell_transform_vs_escape_for_shell_consistency() {
3486        let test_cases = vec![
3487            "simple",
3488            "hello world",
3489            "it's a test",
3490            "double\"quote",
3491            "",
3492            "special;chars|here",
3493            "$(whoami)",
3494            "`uname`",
3495            "$HOME/.ssh",
3496            "line1\nline2",
3497            "tab\there",
3498        ];
3499
3500        for input in test_cases {
3501            // Method 1: escape_for_shell (used by resolve_with)
3502            let method1 = escape_for_shell(input);
3503
3504            // Method 2: TransformOp::Shell (also applied but discarded)
3505            use crate::binding::transform::TransformOp;
3506            let json_val = json!(input);
3507            let method2_val = TransformOp::Shell.apply(&json_val).unwrap();
3508            let method2 = method2_val.as_str().unwrap().to_string();
3509
3510            assert_eq!(
3511                method1, method2,
3512                "Shell escaping methods differ for input '{}': \
3513                 escape_for_shell='{}' vs TransformOp::Shell='{}'",
3514                input, method1, method2
3515            );
3516        }
3517    }
3518
3519    // =========================================================================
3520    // Media Tool Results → Template Resolution (Full Pipeline)
3521    // =========================================================================
3522    //
3523    // End-to-end tests for media tool results flowing through template_resolve().
3524    //
3525    // Architecture:
3526    //   RunContext (task results + media refs)
3527    //     → BindingSpec (with: block declarations)
3528    //       → ResolvedBindings (from_binding_spec resolves eagerly or lazily)
3529    //         → template_resolve() (substitutes {{with.alias}} in strings)
3530    //
3531    // Media refs live in TaskResult.media (side-channel), NOT in TaskResult.output.
3532    // The binding spec intercepts "media" paths and delegates to RunContext.resolve_path().
3533    // This means:
3534    //   - `hash: $gen.media[0].hash` works (binding spec intercepts media prefix)
3535    //   - `{{with.hash}}` resolves to the hash value
3536    //
3537    // Direct template traversal like `{{with.gen.media[0].hash}}` where gen is
3538    // bound to the entire task output does NOT work because the template engine
3539    // traverses the output JSON, which does not contain media refs.
3540
3541    /// Helper: build RunContext + ResolvedBindings for media template tests.
3542    fn media_template_fixtures() -> (RunContext, ResolvedBindings) {
3543        use crate::binding::{BindingEntry, BindingSpec};
3544
3545        let store = RunContext::new();
3546
3547        // Task "gen": image generation with media refs
3548        let gen_media = vec![crate::media::MediaRef {
3549            hash: "blake3:abc123".to_string(),
3550            mime_type: "image/png".to_string(),
3551            size_bytes: 524288,
3552            path: std::path::PathBuf::from("/tmp/cas/ab/c123"),
3553            extension: "png".to_string(),
3554            created_by: "gen".to_string(),
3555            metadata: {
3556                let mut m = serde_json::Map::new();
3557                m.insert("width".to_string(), json!(1024));
3558                m.insert("height".to_string(), json!(768));
3559                m
3560            },
3561        }];
3562        store.insert(
3563            std::sync::Arc::from("gen"),
3564            crate::store::TaskResult::success(
3565                json!({"prompt": "a sunset photo"}),
3566                std::time::Duration::from_secs(3),
3567            )
3568            .with_media(gen_media),
3569        );
3570
3571        // Task "thumb": invoke returns JSON-string output
3572        store.insert(
3573            std::sync::Arc::from("thumb"),
3574            crate::store::TaskResult::success_str(
3575                r#"{"hash":"blake3:def456","mime_type":"image/png","size_bytes":2048,"metadata":{"width":256,"height":192}}"#,
3576                std::time::Duration::from_millis(100),
3577            ),
3578        );
3579
3580        // Build binding spec (simulates the with: block)
3581        let mut spec = BindingSpec::default();
3582        // Direct media ref bindings (resolved eagerly via binding spec interception)
3583        spec.insert(
3584            "source_hash".to_string(),
3585            BindingEntry::new("gen.media[0].hash"),
3586        );
3587        spec.insert(
3588            "source_width".to_string(),
3589            BindingEntry::new("gen.media[0].metadata.width"),
3590        );
3591        // Invoke output bindings (resolved eagerly via JSON-string auto-parse)
3592        spec.insert("thumb".to_string(), BindingEntry::new("thumb"));
3593        spec.insert("thumb_hash".to_string(), BindingEntry::new("thumb.hash"));
3594        spec.insert(
3595            "thumb_width".to_string(),
3596            BindingEntry::new("thumb.metadata.width"),
3597        );
3598
3599        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
3600        (store, bindings)
3601    }
3602
3603    #[test]
3604    fn media_template_resolve_source_hash() {
3605        let (store, bindings) = media_template_fixtures();
3606
3607        let result = resolve("Source image hash: {{with.source_hash}}", &bindings, &store).unwrap();
3608
3609        assert_eq!(result.as_ref(), "Source image hash: blake3:abc123");
3610    }
3611
3612    #[test]
3613    fn media_template_resolve_source_width() {
3614        let (store, bindings) = media_template_fixtures();
3615
3616        let result = resolve("Original width: {{with.source_width}}px", &bindings, &store).unwrap();
3617
3618        assert_eq!(result.as_ref(), "Original width: 1024px");
3619    }
3620
3621    #[test]
3622    fn media_template_resolve_thumb_hash() {
3623        let (store, bindings) = media_template_fixtures();
3624
3625        let result = resolve("Thumbnail hash: {{with.thumb_hash}}", &bindings, &store).unwrap();
3626
3627        assert_eq!(result.as_ref(), "Thumbnail hash: blake3:def456");
3628    }
3629
3630    #[test]
3631    fn media_template_resolve_thumb_nested_width() {
3632        let (store, bindings) = media_template_fixtures();
3633
3634        let result = resolve("Thumb is {{with.thumb_width}}px wide", &bindings, &store).unwrap();
3635
3636        assert_eq!(result.as_ref(), "Thumb is 256px wide");
3637    }
3638
3639    #[test]
3640    fn media_template_thumb_output_traversal_auto_parses_json_string() {
3641        let (store, bindings) = media_template_fixtures();
3642
3643        // Dedicated binding path still works (binding spec resolves the path)
3644        let result = resolve(
3645            "Hash via binding spec: {{with.thumb_hash}}",
3646            &bindings,
3647            &store,
3648        )
3649        .unwrap();
3650        assert_eq!(result.as_ref(), "Hash via binding spec: blake3:def456");
3651
3652        // Template-level traversal on a JSON-string value now auto-parses
3653        // the JSON string and traverses into it (NIKA-253 fix).
3654        // This matches navigate_segments() in binding/resolve.rs.
3655        let result = resolve("Direct: {{with.thumb.hash}}", &bindings, &store).unwrap();
3656        assert_eq!(result.as_ref(), "Direct: blake3:def456");
3657    }
3658
3659    #[test]
3660    fn media_template_thumb_deep_traversal_via_binding_spec() {
3661        let (store, bindings) = media_template_fixtures();
3662
3663        // Deep path thumb.metadata.width works via binding spec (thumb_width)
3664        let result = resolve("Width: {{with.thumb_width}}", &bindings, &store).unwrap();
3665        assert_eq!(result.as_ref(), "Width: 256");
3666    }
3667
3668    #[test]
3669    fn media_template_thumb_output_as_parsed_json_object() {
3670        // When invoke output is stored as Value::Object (not Value::String),
3671        // template-level traversal works. This happens when the runner parses
3672        // JSON before storing in TaskResult (the Value variant, not success_str).
3673        let store = RunContext::new();
3674        store.insert(
3675            std::sync::Arc::from("thumb2"),
3676            crate::store::TaskResult::success(
3677                json!({
3678                    "hash": "blake3:parsed_obj",
3679                    "metadata": { "width": 128 }
3680                }),
3681                std::time::Duration::from_millis(50),
3682            ),
3683        );
3684
3685        let mut bindings = ResolvedBindings::new();
3686        bindings.set(
3687            "thumb2",
3688            json!({"hash": "blake3:parsed_obj", "metadata": {"width": 128}}),
3689        );
3690
3691        let result = resolve(
3692            "Hash: {{with.thumb2.hash}}, Width: {{with.thumb2.metadata.width}}",
3693            &bindings,
3694            &store,
3695        )
3696        .unwrap();
3697
3698        assert_eq!(result.as_ref(), "Hash: blake3:parsed_obj, Width: 128");
3699    }
3700
3701    #[test]
3702    fn media_template_chained_bindings_in_one_template() {
3703        let (store, bindings) = media_template_fixtures();
3704
3705        // Use multiple media bindings in a single template string
3706        let result = resolve(
3707            "Source: {{with.source_hash}}, Thumb: {{with.thumb_hash}}, Width: {{with.thumb_width}}",
3708            &bindings,
3709            &store,
3710        )
3711        .unwrap();
3712
3713        assert_eq!(
3714            result.as_ref(),
3715            "Source: blake3:abc123, Thumb: blake3:def456, Width: 256"
3716        );
3717    }
3718
3719    #[test]
3720    fn media_template_chained_bindings_in_prompt() {
3721        let (store, bindings) = media_template_fixtures();
3722
3723        // Realistic prompt that chains multiple media outputs
3724        let result = resolve(
3725            "The image ({{with.source_hash}}) was resized to {{with.thumb_width}}px. \
3726             The thumbnail hash is {{with.thumb_hash}}.",
3727            &bindings,
3728            &store,
3729        )
3730        .unwrap();
3731
3732        assert_eq!(
3733            result.as_ref(),
3734            "The image (blake3:abc123) was resized to 256px. \
3735             The thumbnail hash is blake3:def456."
3736        );
3737    }
3738
3739    #[test]
3740    fn media_template_no_templates_returns_borrowed() {
3741        let (store, bindings) = media_template_fixtures();
3742
3743        // No templates -> should return Cow::Borrowed (zero allocation)
3744        let result = resolve("plain text without templates", &bindings, &store).unwrap();
3745        assert!(
3746            matches!(result, std::borrow::Cow::Borrowed(_)),
3747            "No-template strings should be zero-alloc Cow::Borrowed"
3748        );
3749    }
3750
3751    #[test]
3752    fn media_template_json_context_escaping() {
3753        let (store, bindings) = media_template_fixtures();
3754
3755        // When a template appears inside a JSON string context, values are
3756        // JSON-escaped. This is important for media hashes in JSON payloads.
3757        let result = resolve(
3758            r#"{"source": "{{with.source_hash}}", "thumb": "{{with.thumb_hash}}"}"#,
3759            &bindings,
3760            &store,
3761        )
3762        .unwrap();
3763
3764        // Parse as JSON to verify it's valid
3765        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
3766        assert_eq!(parsed["source"], "blake3:abc123");
3767        assert_eq!(parsed["thumb"], "blake3:def456");
3768    }
3769
3770    // ═════════════════════════════════════════════════════════════════════════
3771    // Regression tests for binding/template bugs
3772    // ═════════════════════════════════════════════════════════════════════════
3773
3774    /// NIKA-253: nika:chart output (JSON-string) passed to nika:dimensions
3775    /// as `{{with.chart_result.hash}}` failed because the template system
3776    /// did not auto-parse JSON strings during nested traversal.
3777    ///
3778    /// The fix adds try_parse_json_str in resolve(), resolve_with(),
3779    /// resolve_alias_path(), and resolve_for_shell() to match the behavior
3780    /// already present in navigate_segments() (binding/resolve.rs).
3781    #[test]
3782    fn regression_nika253_chart_to_dimensions_json_string_traversal() {
3783        // Simulate how nika:chart output is stored: MediaToolAdapter returns
3784        // a JSON string, run_invoke re-serializes it, make_task_result wraps
3785        // it as Value::String (no output: json policy for invoke tasks).
3786        let store = RunContext::new();
3787        let chart_output_json = serde_json::json!({
3788            "hash": "blake3:af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",
3789            "path": "/tmp/cas/af/1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",
3790            "size_bytes": 12345,
3791            "mime_type": "image/png",
3792            "extension": "png",
3793            "deduplicated": false,
3794            "metadata": { "chart_type": "bar", "width": 800, "height": 500 }
3795        });
3796        // Stored as Value::String (the bug scenario: success_str wraps JSON)
3797        store.insert(
3798            std::sync::Arc::from("gen_chart"),
3799            crate::store::TaskResult::success_str(
3800                chart_output_json.to_string(),
3801                std::time::Duration::from_millis(100),
3802            ),
3803        );
3804
3805        // Binding: chart_result: $gen_chart
3806        let mut spec: crate::binding::BindingSpec = FxHashMap::default();
3807        spec.insert(
3808            "chart_result".to_string(),
3809            crate::binding::BindingEntry::new("gen_chart"),
3810        );
3811        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
3812
3813        // Before the fix: {{with.chart_result.hash}} would fail with
3814        // InvalidTraversal because the template system saw Value::String
3815        // and refused to traverse into it.
3816        //
3817        // After the fix: auto-parse parses the JSON string into a Value::Object,
3818        // enabling .hash traversal.
3819        let result = resolve("hash: {{with.chart_result.hash}}", &bindings, &store).unwrap();
3820        assert_eq!(
3821            result.as_ref(),
3822            "hash: blake3:af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
3823        );
3824
3825        // Deep nested traversal also works
3826        let result = resolve(
3827            "type: {{with.chart_result.metadata.chart_type}}",
3828            &bindings,
3829            &store,
3830        )
3831        .unwrap();
3832        assert_eq!(result.as_ref(), "type: bar");
3833
3834        // Width from metadata
3835        let result = resolve(
3836            "width: {{with.chart_result.metadata.width}}",
3837            &bindings,
3838            &store,
3839        )
3840        .unwrap();
3841        assert_eq!(result.as_ref(), "width: 800");
3842    }
3843
3844    /// NIKA-253: resolve_with (used by run_invoke for param templates)
3845    /// must also auto-parse JSON strings.
3846    #[test]
3847    fn regression_nika253_resolve_with_json_string_traversal() {
3848        let ds = empty_datastore();
3849        let mut with_values = FxHashMap::default();
3850        with_values.insert(
3851            "chart_out".to_string(),
3852            Value::String(r#"{"hash":"blake3:abc123","size_bytes":9999}"#.to_string()),
3853        );
3854
3855        let result = resolve_with("{{chart_out.hash}}", &with_values, &ds).unwrap();
3856        assert_eq!(result.as_ref(), "blake3:abc123");
3857    }
3858
3859    /// Bug 29: normalize_bracket_notation must NOT corrupt literal text outside
3860    /// `{{...}}` blocks. Brackets like `data[0]` in plain text must be preserved.
3861    #[test]
3862    fn regression_bug29_bracket_notation_preserves_literal_text() {
3863        let mut bindings = ResolvedBindings::new();
3864        bindings.set("items", json!(["first", "second", "third"]));
3865        let ds = empty_datastore();
3866
3867        // "data[0]" is literal text, "{{with.items[0]}}" is a template block
3868        let result = resolve("data[0] is {{with.items[0]}}", &bindings, &ds).unwrap();
3869        assert_eq!(
3870            result, "data[0] is first",
3871            "Literal 'data[0]' outside {{}} must NOT be normalized to 'data.0'"
3872        );
3873    }
3874
3875    /// Bug 29: normalize_bracket_notation preserves multiple literal brackets.
3876    #[test]
3877    fn regression_bug29_multiple_literal_brackets() {
3878        let with = make_with(&[("items", json!(["a", "b"]))]);
3879        let ds = empty_datastore();
3880
3881        let result = resolve_with(
3882            "arr[0] and arr[1] are {{items[0]}} and {{items[1]}}",
3883            &with,
3884            &ds,
3885        )
3886        .unwrap();
3887        assert_eq!(
3888            result, "arr[0] and arr[1] are a and b",
3889            "Multiple literal brackets must be preserved"
3890        );
3891    }
3892
3893    /// Bug 29: normalize_bracket_notation direct unit test.
3894    #[test]
3895    fn regression_bug29_normalize_unit_test() {
3896        // Literal brackets outside {{ }} must NOT be changed
3897        assert_eq!(
3898            normalize_bracket_notation("data[0] is {{with.items[0]}}"),
3899            "data[0] is {{with.items.0}}"
3900        );
3901        // No template blocks: brackets left as-is
3902        assert_eq!(
3903            normalize_bracket_notation("array[5] is cool"),
3904            "array[5] is cool"
3905        );
3906        // Brackets only inside {{ }}: normalized
3907        assert_eq!(
3908            normalize_bracket_notation("{{a[0]}} and {{b[1]}}"),
3909            "{{a.0}} and {{b.1}}"
3910        );
3911    }
3912
3913    /// Bug 45: resolve_with must NOT evaluate context/input templates injected
3914    /// via with: values. Cross-pass contamination is blocked by checking the
3915    /// ORIGINAL template for context/inputs markers.
3916    #[test]
3917    fn regression_bug45_no_cross_pass_contamination() {
3918        let with = make_with(&[("user_input", json!("{{context.files.secret}}"))]);
3919        let ds = empty_datastore();
3920        let mut context = LoadedContext::new();
3921        context
3922            .files
3923            .insert("secret".to_string(), json!("TOP_SECRET_VALUE"));
3924        ds.set_context(context);
3925
3926        let result = resolve_with("Result: {{user_input}}", &with, &ds).unwrap();
3927        // The original template "Result: {{user_input}}" does NOT contain "context."
3928        // so Pass 2 must NOT run. The literal "{{context.files.secret}}" stays.
3929        assert_eq!(result, "Result: {{context.files.secret}}");
3930        assert!(
3931            !result.contains("TOP_SECRET_VALUE"),
3932            "with: value containing {{context.files.x}} must NOT be evaluated"
3933        );
3934    }
3935
3936    /// Bug 45: resolve_with must NOT evaluate inputs templates injected via with: values.
3937    #[test]
3938    fn regression_bug45_no_inputs_injection() {
3939        let with = make_with(&[("val", json!("{{inputs.locale}}"))]);
3940        let ds = empty_datastore();
3941        let mut inputs = FxHashMap::default();
3942        inputs.insert("locale".to_string(), json!("fr-FR"));
3943        ds.set_inputs(inputs);
3944
3945        let result = resolve_with("Got: {{val}}", &with, &ds).unwrap();
3946        assert_eq!(result, "Got: {{inputs.locale}}");
3947        assert!(
3948            !result.contains("fr-FR"),
3949            "with: value containing {{inputs.x}} must NOT be evaluated"
3950        );
3951    }
3952
3953    /// Bug 45: when the original template DOES contain context refs, they still resolve.
3954    #[test]
3955    fn regression_bug45_legitimate_context_still_resolves() {
3956        let with = make_with(&[("name", json!("Alice"))]);
3957        let ds = empty_datastore();
3958        let mut context = LoadedContext::new();
3959        context
3960            .files
3961            .insert("brand".to_string(), json!("SuperNovae"));
3962        ds.set_context(context);
3963
3964        // Original template contains both alias and context refs
3965        let result =
3966            resolve_with("Hello {{name}} from {{context.files.brand}}", &with, &ds).unwrap();
3967        assert_eq!(result, "Hello Alice from SuperNovae");
3968    }
3969
3970    /// Bug 47: shell transform must not be double-applied in resolve_with.
3971    /// Verifies the output is correctly shell-escaped exactly once.
3972    #[test]
3973    fn regression_bug47_shell_not_double_applied() {
3974        let with = make_with(&[("val", json!("hello world"))]);
3975        let ds = empty_datastore();
3976        let result = resolve_with("{{val | shell}}", &with, &ds).unwrap();
3977        // Correct: single shell escape wraps in single quotes
3978        assert_eq!(result, "'hello world'");
3979        // If double-applied, would be "''hello world''" or similar nested quoting
3980    }
3981
3982    /// Bug 47: shell transform with other transforms must not double-apply.
3983    #[test]
3984    fn regression_bug47_shell_with_chain_not_double_applied() {
3985        let with = make_with(&[("val", json!("Hello World"))]);
3986        let ds = empty_datastore();
3987        let result = resolve_with("{{val | lower | shell}}", &with, &ds).unwrap();
3988        // lower applied first, then shell escape
3989        assert_eq!(result, "'hello world'");
3990    }
3991
3992    /// Bug 47: shell transform on value with quotes must escape correctly once.
3993    #[test]
3994    fn regression_bug47_shell_with_quotes() {
3995        let with = make_with(&[("val", json!("it's a test"))]);
3996        let ds = empty_datastore();
3997        let result = resolve_with("{{val | shell}}", &with, &ds).unwrap();
3998        // Single correct shell escape: 'it'\''s a test'
3999        assert_eq!(result, "'it'\\''s a test'");
4000    }
4001}