Skip to main content

nika_engine/binding/
resolve.rs

1//! Resolved Bindings - runtime value resolution
2//!
3//! ResolvedBindings holds resolved values from `with:` blocks for template resolution.
4//! Supports both eager (immediate) and lazy (deferred) resolution.
5//!
6//! ## Binding syntax
7//!
8//! Unified syntax: `alias: task.path [?? default]`
9//! Extended syntax: `alias: {path: task.path, lazy: true}`
10//!
11//! Rich typed paths with transforms:
12//! ```yaml
13//! with:
14//!   summary: $step1.abstract | lower | trim ?? "No abstract"
15//!   data:
16//!     from: $step1.data
17//!     type: object
18//!     transform: sort_keys
19//! ```
20//!
21//! Data flow:
22//! ```text
23//! WithEntry.source (BindingPath)
24//!     ↓ dispatch by BindingSource
25//!     ├── Task(id) → datastore.get_output(id) + navigate PathSegments
26//!     ├── Input(sub) → datastore.resolve_input_path("inputs.{sub}")
27//!     ├── Context(sub) → datastore.get_context_file/session
28//!     ├── Env(var) → std::env::var(var)
29//!     └── LoopVar(_) → error (should be pre-resolved)
30//!     ↓
31//! Apply WithEntry.transform (TransformExpr pipeline)
32//!     ↓
33//! Apply WithEntry.default (if value is null/missing)
34//!     ↓
35//! Validate WithEntry.binding_type (BindingType constraint)
36//!     ↓
37//! LazyBinding::Resolved(value) or error
38//! ```
39//!
40//! Uses FxHashMap for faster hashing (consistent with Dag).
41
42use std::sync::Arc;
43
44use rustc_hash::FxHashMap;
45use serde_json::Value;
46
47use super::jsonpath;
48use crate::error::NikaError;
49use crate::event::EventKind;
50use crate::store::RunContext;
51
52use super::transform::TransformExpr;
53use super::types::{BindingPath, BindingSource, BindingType, PathSegment};
54use super::{BindingEntry, BindingSpec, WithEntry, WithSpec};
55
56/// Lazy binding state - either resolved or pending
57///
58/// Pending now stores `BindingPath` + optional `TransformExpr` + optional default
59/// instead of raw `String` path. This enables typed source dispatch and transform
60/// application during lazy resolution.
61#[derive(Debug, Clone)]
62pub enum LazyBinding {
63    /// Already resolved to a concrete value (eager bindings)
64    Resolved(Value),
65    /// Pending resolution — stores raw path string
66    Pending {
67        path: String,
68        default: Option<Value>,
69    },
70    /// Pending typed resolution — stores BindingPath + transforms
71    PendingWithEntry {
72        source: BindingPath,
73        binding_type: BindingType,
74        default: Option<Value>,
75        transform: Option<TransformExpr>,
76    },
77}
78
79impl LazyBinding {
80    /// Check if this binding is pending resolution
81    pub fn is_pending(&self) -> bool {
82        matches!(
83            self,
84            LazyBinding::Pending { .. } | LazyBinding::PendingWithEntry { .. }
85        )
86    }
87
88    /// Get the value if already resolved
89    pub fn get_value(&self) -> Option<&Value> {
90        match self {
91            LazyBinding::Resolved(v) => Some(v),
92            LazyBinding::Pending { .. } | LazyBinding::PendingWithEntry { .. } => None,
93        }
94    }
95}
96
97/// Resolved bindings from with: block (alias -> value or pending)
98///
99/// Uses FxHashMap for faster hashing on small string keys.
100/// Supports both eager and lazy bindings.
101/// Also provides `from_with_spec()` for the typed binding system.
102#[derive(Debug, Clone, Default)]
103pub struct ResolvedBindings {
104    /// Alias -> binding mappings (resolved or pending)
105    bindings: FxHashMap<String, LazyBinding>,
106    /// Alias -> source task ID (for media path resolution)
107    ///
108    /// When a binding like `img: $gen_img` is created, this maps
109    /// `"img"` -> `"gen_img"`. This is needed because media refs live
110    /// in the TaskResult side-channel, not in the task output value.
111    /// Without this, templates like `{{with.img.media[0].hash}}` and
112    /// binary artifact `source: img` cannot resolve media paths.
113    source_tasks: FxHashMap<String, String>,
114}
115
116impl ResolvedBindings {
117    /// Create empty bindings
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    // ═══════════════════════════════════════════════════════════════
123    // String-path resolution: from_binding_spec (BindingEntry / BindingSpec)
124    // ═══════════════════════════════════════════════════════════════
125
126    /// Build bindings from with: block by resolving paths from datastore
127    ///
128    /// Unified resolution for both syntax styles:
129    /// - String: `task.path [?? default]` → eager resolution
130    /// - Object: `{path, lazy?, default?}` → lazy or eager based on flag
131    ///
132    /// Lazy bindings are stored as Pending and resolved on first access.
133    /// Eager bindings are resolved immediately and fail if source is missing.
134    ///
135    /// Returns empty bindings if binding_spec is None.
136    pub fn from_binding_spec(
137        binding_spec: Option<&BindingSpec>,
138        datastore: &RunContext,
139    ) -> Result<Self, NikaError> {
140        let Some(spec) = binding_spec else {
141            return Ok(Self::new());
142        };
143
144        let mut resolved = Self::new();
145
146        for (alias, entry) in spec {
147            // Track source task ID for media path resolution
148            let (task_id, _) = split_path(&entry.path);
149            if !task_id.starts_with("inputs.")
150                && !task_id.starts_with("context.")
151                && !task_id.starts_with("env.")
152            {
153                resolved
154                    .source_tasks
155                    .insert(alias.clone(), task_id.to_string());
156            }
157
158            if entry.is_lazy() {
159                // Lazy binding - defer resolution
160                resolved.bindings.insert(
161                    alias.clone(),
162                    LazyBinding::Pending {
163                        path: entry.path.clone(),
164                        default: entry.default.clone(),
165                    },
166                );
167            } else {
168                // Eager binding - resolve immediately
169                let value = resolve_entry(entry, alias, datastore)?;
170                resolved
171                    .bindings
172                    .insert(alias.clone(), LazyBinding::Resolved(value));
173            }
174        }
175
176        Ok(resolved)
177    }
178
179    // ═══════════════════════════════════════════════════════════════
180    // Typed resolution: from_with_spec (WithEntry / WithSpec)
181    // ═══════════════════════════════════════════════════════════════
182
183    /// Build bindings from with: spec by resolving typed BindingPaths
184    ///
185    /// Resolution order per entry:
186    /// 1. Dispatch by BindingSource (Task/Input/Context/Env)
187    /// 2. Navigate PathSegments for nested value access
188    /// 3. Apply TransformExpr pipeline (if present)
189    /// 4. Apply default value (if result is null/missing)
190    /// 5. Validate BindingType constraint
191    ///
192    /// Lazy bindings are stored as PendingWithEntry for later resolution.
193    pub fn from_with_spec(
194        with_spec: Option<&WithSpec>,
195        datastore: &RunContext,
196    ) -> Result<Self, NikaError> {
197        let Some(spec) = with_spec else {
198            return Ok(Self::new());
199        };
200
201        let mut bindings = Self::new();
202
203        for (alias, entry) in spec {
204            // Track source task ID for media path resolution
205            if let Some(task_id) = entry.source.task_id() {
206                bindings
207                    .source_tasks
208                    .insert(alias.clone(), task_id.to_string());
209            }
210
211            if entry.is_lazy() {
212                bindings.bindings.insert(
213                    alias.clone(),
214                    LazyBinding::PendingWithEntry {
215                        source: entry.source.clone(),
216                        binding_type: entry.binding_type,
217                        default: entry.default.clone(),
218                        transform: entry.transform.clone(),
219                    },
220                );
221            } else {
222                let value = resolve_with_entry(entry, alias, datastore)?;
223                bindings
224                    .bindings
225                    .insert(alias.clone(), LazyBinding::Resolved(value));
226            }
227        }
228
229        Ok(bindings)
230    }
231
232    /// Build bindings from with: spec with event collection for telemetry.
233    ///
234    /// Same as `from_with_spec` but collects binding events (defaults applied,
235    /// transforms executed, env vars resolved) for the caller to emit.
236    pub fn from_with_spec_traced(
237        with_spec: Option<&WithSpec>,
238        datastore: &RunContext,
239        task_id: &Arc<str>,
240    ) -> Result<(Self, Vec<EventKind>), NikaError> {
241        let Some(spec) = with_spec else {
242            return Ok((Self::new(), vec![]));
243        };
244
245        let mut bindings = Self::new();
246        let mut events = Vec::new();
247
248        for (alias, entry) in spec {
249            if let Some(tid) = entry.source.task_id() {
250                bindings.source_tasks.insert(alias.clone(), tid.to_string());
251            }
252
253            if entry.is_lazy() {
254                bindings.bindings.insert(
255                    alias.clone(),
256                    LazyBinding::PendingWithEntry {
257                        source: entry.source.clone(),
258                        binding_type: entry.binding_type,
259                        default: entry.default.clone(),
260                        transform: entry.transform.clone(),
261                    },
262                );
263            } else {
264                let value =
265                    resolve_with_entry_traced(entry, alias, datastore, task_id, &mut events)?;
266                bindings
267                    .bindings
268                    .insert(alias.clone(), LazyBinding::Resolved(value));
269            }
270        }
271
272        Ok((bindings, events))
273    }
274
275    // ═══════════════════════════════════════════════════════════════
276    // Common API
277    // ═══════════════════════════════════════════════════════════════
278
279    /// Set a resolved value (always eager)
280    pub fn set(&mut self, alias: impl Into<String>, value: Value) {
281        self.bindings
282            .insert(alias.into(), LazyBinding::Resolved(value));
283    }
284
285    /// Set a resolved value with source task ID tracking.
286    ///
287    /// Use this when the binding originates from a task output, so that
288    /// media path resolution (e.g., `{{with.alias.media[0].hash}}`) can
289    /// trace back to the correct task's media refs.
290    pub fn set_with_source(
291        &mut self,
292        alias: impl Into<String>,
293        value: Value,
294        source_task_id: impl Into<String>,
295    ) {
296        let alias = alias.into();
297        self.source_tasks
298            .insert(alias.clone(), source_task_id.into());
299        self.bindings.insert(alias, LazyBinding::Resolved(value));
300    }
301
302    /// Get the source task ID for a binding alias.
303    ///
304    /// Returns `Some("gen_img")` when the binding was `img: $gen_img`.
305    /// Used by artifact processor to resolve media paths and binary artifact sources.
306    pub fn source_task_id(&self, alias: &str) -> Option<&str> {
307        self.source_tasks.get(alias).map(|s| s.as_str())
308    }
309
310    /// Get a resolved value (only works for already-resolved bindings)
311    ///
312    /// For lazy bindings that haven't been resolved yet, returns None.
313    /// Use `get_resolved()` to force resolution of lazy bindings.
314    pub fn get(&self, alias: &str) -> Option<&Value> {
315        self.bindings.get(alias).and_then(|b| b.get_value())
316    }
317
318    /// Get a resolved value, resolving lazy bindings on demand
319    ///
320    /// For eager bindings, returns the pre-resolved value.
321    /// For lazy bindings, resolves from datastore on first call.
322    ///
323    /// Note: This doesn't cache the resolution - each call re-resolves.
324    /// This is intentional to support changing datastore values.
325    pub fn get_resolved(&self, alias: &str, datastore: &RunContext) -> Result<Value, NikaError> {
326        match self.bindings.get(alias) {
327            Some(LazyBinding::Resolved(value)) => Ok(value.clone()),
328            Some(LazyBinding::Pending { path, default }) => {
329                // String-path: resolve via BindingEntry
330                let entry = BindingEntry {
331                    path: path.clone(),
332                    default: default.clone(),
333                    lazy: true,
334                };
335                resolve_entry(&entry, alias, datastore)
336            }
337            Some(LazyBinding::PendingWithEntry {
338                source,
339                binding_type,
340                default,
341                transform,
342            }) => {
343                // Typed: resolve via WithEntry
344                let entry = WithEntry {
345                    source: source.clone(),
346                    binding_type: *binding_type,
347                    default: default.clone(),
348                    lazy: true,
349                    transform: transform.clone(),
350                };
351                resolve_with_entry(&entry, alias, datastore)
352            }
353            None => Err(NikaError::BindingNotFound {
354                alias: alias.to_string(),
355            }),
356        }
357    }
358
359    /// Check if a binding is lazy (pending resolution)
360    pub fn is_lazy(&self, alias: &str) -> bool {
361        self.bindings
362            .get(alias)
363            .map(|b| b.is_pending())
364            .unwrap_or(false)
365    }
366
367    /// Check if context has any bindings
368    pub fn is_empty(&self) -> bool {
369        self.bindings.is_empty()
370    }
371
372    /// Iterate over resolved bindings (alias, value pairs)
373    ///
374    /// Only returns already-resolved bindings. Pending lazy bindings are skipped.
375    /// Use this for event logging where we want to capture resolved values.
376    pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
377        self.bindings
378            .iter()
379            .filter_map(|(alias, binding)| binding.get_value().map(|value| (alias.as_str(), value)))
380    }
381
382    /// Serialize context to JSON Value for event logging
383    ///
384    /// Returns the full resolved inputs as a JSON object.
385    /// Lazy bindings that haven't been resolved are represented as marker objects.
386    /// Used by EventLog for TaskStarted events (inputs field).
387    pub fn to_value(&self) -> Value {
388        let mut map = serde_json::Map::new();
389        for (alias, binding) in &self.bindings {
390            match binding {
391                LazyBinding::Resolved(v) => {
392                    map.insert(alias.clone(), v.clone());
393                }
394                LazyBinding::Pending { path, default: _ } => {
395                    // Represent pending as a marker object
396                    map.insert(
397                        alias.clone(),
398                        serde_json::json!({"__lazy__": true, "path": path}),
399                    );
400                }
401                LazyBinding::PendingWithEntry {
402                    source, default: _, ..
403                } => {
404                    // Represent pending with typed path
405                    map.insert(
406                        alias.clone(),
407                        serde_json::json!({"__lazy__": true, "path": source.to_string()}),
408                    );
409                }
410            }
411        }
412        Value::Object(map)
413    }
414}
415
416// ═══════════════════════════════════════════════════════════════
417// String-path resolution: BindingEntry (simple path bindings)
418// ═══════════════════════════════════════════════════════════════
419
420/// Resolve a single BindingEntry to a Value
421///
422/// Unified resolution logic:
423/// 1. Check for inputs.* path (workflow inputs support)
424/// 2. Extract task_id from path (first segment)
425/// 3. Get task output from datastore
426/// 4. Resolve remaining path within output
427/// 5. Apply default if value is null/missing
428fn resolve_entry(
429    entry: &BindingEntry,
430    alias: &str,
431    datastore: &RunContext,
432) -> Result<Value, NikaError> {
433    let path = &entry.path;
434
435    // Check for inputs.* path first
436    if path.starts_with("inputs.") {
437        let value = datastore.resolve_input_path(path);
438        return match value {
439            Some(v) if !v.is_null() => Ok(v),
440            Some(_) => entry
441                .default
442                .as_ref()
443                .cloned()
444                .ok_or_else(|| NikaError::NullValue {
445                    path: path.clone(),
446                    alias: alias.to_string(),
447                }),
448            None => entry
449                .default
450                .as_ref()
451                .cloned()
452                .ok_or_else(|| NikaError::PathNotFound { path: path.clone() }),
453        };
454    }
455
456    // Split path into task_id and remaining path
457    let (task_id, field_path) = split_path(path);
458
459    // Intercept media paths: task_id.media, task_id.media[0].hash, etc.
460    // The media side-channel is in TaskResult.media, not in TaskResult.output,
461    // so get_output() cannot see it. Delegate to resolve_path() which handles
462    // both output and media resolution.
463    if let Some(fp) = field_path {
464        if fp == "media" || fp.starts_with("media.") || fp.starts_with("media[") {
465            let value = datastore.resolve_path(path);
466            return match value {
467                Some(v) if !v.is_null() => Ok(v),
468                Some(_) => entry
469                    .default
470                    .as_ref()
471                    .cloned()
472                    .ok_or_else(|| NikaError::NullValue {
473                        path: path.clone(),
474                        alias: alias.to_string(),
475                    }),
476                None => entry
477                    .default
478                    .as_ref()
479                    .cloned()
480                    .ok_or_else(|| NikaError::PathNotFound { path: path.clone() }),
481            };
482        }
483    }
484
485    // Resolve the value from task output
486    let value = match datastore.get_output(task_id) {
487        Some(output) => {
488            if let Some(fp) = field_path {
489                jsonpath::resolve(&output, fp)?
490            } else {
491                Some((*output).clone())
492            }
493        }
494        None => None,
495    };
496
497    // Apply default if value is null or missing
498    match value {
499        Some(v) if !v.is_null() => Ok(v),
500        Some(_) => entry
501            .default
502            .as_ref()
503            .cloned()
504            .ok_or_else(|| NikaError::NullValue {
505                path: path.clone(),
506                alias: alias.to_string(),
507            }),
508        None => entry
509            .default
510            .as_ref()
511            .cloned()
512            .ok_or_else(|| NikaError::PathNotFound { path: path.clone() }),
513    }
514}
515
516/// Split a path into task_id and remaining field path
517///
518/// Examples:
519/// - "weather" -> ("weather", None)
520/// - "weather.summary" -> ("weather", Some("summary"))
521/// - "weather.data.temp" -> ("weather", Some("data.temp"))
522fn split_path(path: &str) -> (&str, Option<&str>) {
523    if let Some(dot_idx) = path.find('.') {
524        let task_id = &path[..dot_idx];
525        let field_path = &path[dot_idx + 1..];
526        (task_id, Some(field_path))
527    } else {
528        (path, None)
529    }
530}
531
532// ═══════════════════════════════════════════════════════════════
533// Typed resolution: WithEntry (BindingPath dispatch + transforms)
534// ═══════════════════════════════════════════════════════════════
535
536/// Resolve a single WithEntry to a Value using typed BindingPath dispatch
537///
538/// Resolution pipeline:
539/// 1. Dispatch by BindingSource to get raw value
540/// 2. Navigate PathSegments for nested access
541/// 3. Apply transform pipeline (if present)
542/// 4. Apply default (if value is null/missing, AFTER transforms)
543/// 5. Validate BindingType constraint
544fn resolve_with_entry(
545    entry: &WithEntry,
546    alias: &str,
547    datastore: &RunContext,
548) -> Result<Value, NikaError> {
549    let path_str = entry.source.to_string();
550
551    // Step 1+2: Dispatch by source and navigate segments
552    let raw_value = resolve_binding_path(&entry.source, alias, datastore)?;
553
554    // Step 3: Apply transforms
555    let transformed = match (&raw_value, &entry.transform) {
556        (Some(v), Some(expr)) if !v.is_null() => {
557            Some(expr.apply(v).map_err(|e| NikaError::PathNotFound {
558                path: format!("{} (transform error: {})", path_str, e),
559            })?)
560        }
561        _ => raw_value,
562    };
563
564    // Step 4: Apply default if null/missing
565    let value = match transformed {
566        Some(v) if !v.is_null() => v,
567        Some(_null) => {
568            // Value is null — use default or error
569            match &entry.default {
570                Some(d) => d.clone(),
571                None => {
572                    return Err(NikaError::NullValue {
573                        path: path_str,
574                        alias: alias.to_string(),
575                    });
576                }
577            }
578        }
579        None => {
580            // Value not found — use default or error
581            match &entry.default {
582                Some(d) => d.clone(),
583                None => {
584                    return Err(NikaError::PathNotFound { path: path_str });
585                }
586            }
587        }
588    };
589
590    // Step 5: Validate BindingType constraint
591    validate_binding_type(&value, entry.binding_type, alias, &path_str)?;
592
593    Ok(value)
594}
595
596/// Dispatch resolution by BindingSource variant
597///
598/// Returns the raw value before transforms/defaults are applied.
599/// Returns `Ok(None)` if the source exists but the specific path is missing.
600fn resolve_binding_path(
601    binding_path: &BindingPath,
602    alias: &str,
603    datastore: &RunContext,
604) -> Result<Option<Value>, NikaError> {
605    match &binding_path.source {
606        BindingSource::Task(task_id) => {
607            // Intercept media paths: segments starting with Field("media")
608            // Media data lives in TaskResult.media (side-channel), not in
609            // TaskResult.output. Delegate to resolve_path() which handles both.
610            if matches!(
611                binding_path.segments.first(),
612                Some(crate::binding::types::PathSegment::Field(f)) if f.as_ref() == "media"
613            ) {
614                // Reconstruct the full dot-separated path for resolve_path
615                let full_path = format!(
616                    "{}{}",
617                    task_id,
618                    binding_path
619                        .segments
620                        .iter()
621                        .fold(String::new(), |mut acc, seg| {
622                            match seg {
623                                crate::binding::types::PathSegment::Field(f) => {
624                                    acc.push('.');
625                                    acc.push_str(f);
626                                }
627                                crate::binding::types::PathSegment::Index(i) => {
628                                    acc.push_str(&format!("[{}]", i));
629                                }
630                            }
631                            acc
632                        })
633                );
634                return Ok(datastore.resolve_path(&full_path));
635            }
636
637            let output = match datastore.get_output(task_id) {
638                Some(o) => o,
639                None => return Ok(None),
640            };
641
642            // Navigate path segments through the value
643            navigate_segments(&output, &binding_path.segments)
644        }
645
646        BindingSource::Input(sub_path) => {
647            // RunContext.resolve_input_path expects "inputs.{sub_path}" format
648            let full_path = format!("inputs.{}", sub_path);
649            Ok(datastore.resolve_input_path(&full_path))
650        }
651
652        BindingSource::Context(sub_path) => {
653            // Delegate to resolve_context_path which handles nested navigation:
654            //   "files.brand"        → get file "brand" (full content)
655            //   "files.brand.colors" → get file "brand", then navigate into .colors
656            //   "session"            → full session object
657            //   "session.focus"      → session field "focus"
658            let full_path = format!("context.{}", sub_path);
659            Ok(datastore.resolve_context_path(&full_path))
660        }
661
662        BindingSource::Env(var_name) => {
663            let name_upper = var_name.to_uppercase();
664            // Block known secret patterns (API keys, tokens, passwords)
665            const SECRET_PATTERNS: &[&str] =
666                &["KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL", "AUTH"];
667            let is_secret = SECRET_PATTERNS.iter().any(|p| name_upper.contains(p));
668            // Allow NIKA_ prefix or safe system variables only
669            const SAFE_VARS: &[&str] = &[
670                "PATH", "HOME", "USER", "SHELL", "LANG", "TERM", "PWD", "TMPDIR", "TZ",
671            ];
672            let is_allowed =
673                name_upper.starts_with("NIKA_") || SAFE_VARS.iter().any(|v| name_upper == *v);
674            if is_secret || !is_allowed {
675                tracing::warn!(var = %var_name, "Blocked $env access to restricted variable");
676                Ok(None)
677            } else {
678                match std::env::var(var_name.as_ref()) {
679                    Ok(val) => Ok(Some(Value::String(val))),
680                    Err(_) => Ok(None),
681                }
682            }
683        }
684
685        BindingSource::LoopVar(name) => {
686            // Loop variables should be pre-resolved by the executor before reaching here.
687            // If we get here, it means the loop variable wasn't set.
688            Err(NikaError::BindingNotFound {
689                alias: format!("{} (loop variable '{}' not pre-resolved)", alias, name),
690            })
691        }
692    }
693}
694
695/// Same as resolve_with_entry but collects telemetry events
696fn resolve_with_entry_traced(
697    entry: &WithEntry,
698    alias: &str,
699    datastore: &RunContext,
700    task_id: &Arc<str>,
701    events: &mut Vec<EventKind>,
702) -> Result<Value, NikaError> {
703    let path_str = entry.source.to_string();
704
705    // Step 1+2: Dispatch by source and navigate segments
706    let raw_value = resolve_binding_path_traced(&entry.source, alias, datastore, task_id, events)?;
707
708    // Step 3: Apply transforms
709    let transformed = match (&raw_value, &entry.transform) {
710        (Some(v), Some(expr)) if !v.is_null() => {
711            let result = expr.apply(v).map_err(|e| NikaError::PathNotFound {
712                path: format!("{} (transform error: {})", path_str, e),
713            })?;
714            // EMIT: BindingTransformApplied
715            events.push(EventKind::BindingTransformApplied {
716                task_id: Arc::clone(task_id),
717                alias: alias.to_string(),
718                transform_chain: format!("{:?}", expr),
719            });
720            Some(result)
721        }
722        _ => raw_value,
723    };
724
725    // Step 4: Apply default if null/missing
726    let value = match transformed {
727        Some(v) if !v.is_null() => v,
728        Some(_null) => {
729            match &entry.default {
730                Some(d) => {
731                    // EMIT: BindingDefaultApplied
732                    events.push(EventKind::BindingDefaultApplied {
733                        task_id: Arc::clone(task_id),
734                        alias: alias.to_string(),
735                        path: path_str.clone(),
736                        default_value: d.clone(),
737                    });
738                    d.clone()
739                }
740                None => {
741                    return Err(NikaError::NullValue {
742                        path: path_str,
743                        alias: alias.to_string(),
744                    });
745                }
746            }
747        }
748        None => {
749            match &entry.default {
750                Some(d) => {
751                    // EMIT: BindingDefaultApplied
752                    events.push(EventKind::BindingDefaultApplied {
753                        task_id: Arc::clone(task_id),
754                        alias: alias.to_string(),
755                        path: path_str.clone(),
756                        default_value: d.clone(),
757                    });
758                    d.clone()
759                }
760                None => {
761                    return Err(NikaError::PathNotFound { path: path_str });
762                }
763            }
764        }
765    };
766
767    // Step 5: Validate BindingType
768    validate_binding_type(&value, entry.binding_type, alias, &path_str)?;
769
770    Ok(value)
771}
772
773/// Same as resolve_binding_path but collects env var resolution events
774fn resolve_binding_path_traced(
775    binding_path: &BindingPath,
776    alias: &str,
777    datastore: &RunContext,
778    task_id: &Arc<str>,
779    events: &mut Vec<EventKind>,
780) -> Result<Option<Value>, NikaError> {
781    match &binding_path.source {
782        BindingSource::Env(var_name) => {
783            let result = std::env::var(var_name.as_ref());
784            let found = result.is_ok();
785            // EMIT: BindingEnvResolved
786            events.push(EventKind::BindingEnvResolved {
787                task_id: Arc::clone(task_id),
788                var_name: var_name.to_string(),
789                found,
790            });
791            match result {
792                Ok(val) => Ok(Some(Value::String(val))),
793                Err(_) => Ok(None),
794            }
795        }
796        // For all other sources, delegate to the original function
797        _ => resolve_binding_path(binding_path, alias, datastore),
798    }
799}
800
801/// Navigate a sequence of PathSegments through a JSON value
802///
803/// Returns `Ok(None)` if a segment doesn't match (missing field, out-of-bounds index).
804fn navigate_segments(value: &Value, segments: &[PathSegment]) -> Result<Option<Value>, NikaError> {
805    if segments.is_empty() {
806        return Ok(Some(value.clone()));
807    }
808
809    // Auto-parse JSON strings so exec: output like '{"name":"Nika"}'
810    // can be navigated with segments like .name
811    let parsed;
812    let root = if let Some(v) = crate::binding::jsonpath::try_parse_json_str(value) {
813        parsed = v;
814        &parsed
815    } else {
816        value
817    };
818
819    let mut current = root;
820    for segment in segments {
821        match segment {
822            PathSegment::Field(name) => match current {
823                Value::Object(map) => match map.get(name.as_ref()) {
824                    Some(v) => current = v,
825                    None => return Ok(None),
826                },
827                _ => return Ok(None),
828            },
829            PathSegment::Index(idx) => match current {
830                Value::Array(arr) => match arr.get(*idx) {
831                    Some(v) => current = v,
832                    None => return Ok(None),
833                },
834                _ => return Ok(None),
835            },
836        }
837    }
838
839    Ok(Some(current.clone()))
840}
841
842/// Validate that a value matches the expected BindingType constraint
843///
844/// BindingType::Any always passes. Other types check the JSON value variant.
845fn validate_binding_type(
846    value: &Value,
847    binding_type: BindingType,
848    alias: &str,
849    path: &str,
850) -> Result<(), NikaError> {
851    let matches = match binding_type {
852        BindingType::Any => true,
853        BindingType::String => value.is_string(),
854        BindingType::Number => value.is_number(),
855        BindingType::Integer => value.is_i64() || value.is_u64(),
856        BindingType::Boolean => value.is_boolean(),
857        BindingType::Array => value.is_array(),
858        BindingType::Object => value.is_object(),
859    };
860
861    if !matches {
862        return Err(NikaError::BindingTypeMismatch {
863            expected: binding_type.to_string(),
864            actual: json_type_name(value).to_string(),
865            path: format!("{} (alias: {})", path, alias),
866        });
867    }
868
869    Ok(())
870}
871
872/// Get a human-readable type name for a JSON value
873fn json_type_name(value: &Value) -> &'static str {
874    match value {
875        Value::Null => "null",
876        Value::Bool(_) => "boolean",
877        Value::Number(_) => "number",
878        Value::String(_) => "string",
879        Value::Array(_) => "array",
880        Value::Object(_) => "object",
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use crate::binding::types::BindingPath;
888    use crate::store::TaskResult;
889    use serde_json::json;
890    use std::sync::Arc;
891    use std::time::Duration;
892
893    // ═══════════════════════════════════════════════════════════════
894    // Basic tests (common API)
895    // ═══════════════════════════════════════════════════════════════
896
897    #[test]
898    fn set_and_get() {
899        let mut bindings = ResolvedBindings::new();
900        bindings.set("forecast", json!("Sunny"));
901
902        assert_eq!(bindings.get("forecast"), Some(&json!("Sunny")));
903        assert_eq!(bindings.get("unknown"), None);
904    }
905
906    #[test]
907    fn is_empty() {
908        let mut bindings = ResolvedBindings::new();
909        assert!(bindings.is_empty());
910
911        bindings.set("key", json!("value"));
912        assert!(!bindings.is_empty());
913    }
914
915    #[test]
916    fn from_binding_spec_none() {
917        let store = RunContext::new();
918        let bindings = ResolvedBindings::from_binding_spec(None, &store).unwrap();
919        assert!(bindings.is_empty());
920    }
921
922    #[test]
923    fn from_with_spec_none() {
924        let store = RunContext::new();
925        let bindings = ResolvedBindings::from_with_spec(None, &store).unwrap();
926        assert!(bindings.is_empty());
927    }
928
929    // ═══════════════════════════════════════════════════════════════
930    // String-path resolution tests (BindingEntry / BindingSpec)
931    // ═══════════════════════════════════════════════════════════════
932
933    #[test]
934    fn resolve_simple_path() {
935        let store = RunContext::new();
936        store.insert(
937            Arc::from("weather"),
938            TaskResult::success(json!({"summary": "Sunny"}), Duration::from_secs(1)),
939        );
940
941        let mut spec = BindingSpec::default();
942        spec.insert("forecast".to_string(), BindingEntry::new("weather.summary"));
943
944        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
945        assert_eq!(bindings.get("forecast"), Some(&json!("Sunny")));
946    }
947
948    #[test]
949    fn resolve_entire_task_output() {
950        let store = RunContext::new();
951        store.insert(
952            Arc::from("weather"),
953            TaskResult::success(
954                json!({"summary": "Sunny", "temp": 25}),
955                Duration::from_secs(1),
956            ),
957        );
958
959        let mut spec = BindingSpec::default();
960        spec.insert("data".to_string(), BindingEntry::new("weather"));
961
962        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
963        assert_eq!(
964            bindings.get("data"),
965            Some(&json!({"summary": "Sunny", "temp": 25}))
966        );
967    }
968
969    #[test]
970    fn resolve_nested_path() {
971        let store = RunContext::new();
972        store.insert(
973            Arc::from("weather"),
974            TaskResult::success(
975                json!({"data": {"temp": {"celsius": 25}}}),
976                Duration::from_secs(1),
977            ),
978        );
979
980        let mut spec = BindingSpec::default();
981        spec.insert(
982            "temp".to_string(),
983            BindingEntry::new("weather.data.temp.celsius"),
984        );
985
986        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
987        assert_eq!(bindings.get("temp"), Some(&json!(25)));
988    }
989
990    #[test]
991    fn resolve_with_default_on_missing() {
992        let store = RunContext::new();
993
994        let mut spec = BindingSpec::default();
995        spec.insert(
996            "forecast".to_string(),
997            BindingEntry::with_default("weather.summary", json!("Unknown")),
998        );
999
1000        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1001        assert_eq!(bindings.get("forecast"), Some(&json!("Unknown")));
1002    }
1003
1004    #[test]
1005    fn resolve_with_default_on_null() {
1006        let store = RunContext::new();
1007        store.insert(
1008            Arc::from("weather"),
1009            TaskResult::success(json!({"summary": null}), Duration::from_secs(1)),
1010        );
1011
1012        let mut spec = BindingSpec::default();
1013        spec.insert(
1014            "forecast".to_string(),
1015            BindingEntry::with_default("weather.summary", json!("N/A")),
1016        );
1017
1018        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1019        assert_eq!(bindings.get("forecast"), Some(&json!("N/A")));
1020    }
1021
1022    #[test]
1023    fn resolve_with_default_object() {
1024        let store = RunContext::new();
1025
1026        let mut spec = BindingSpec::default();
1027        spec.insert(
1028            "cfg".to_string(),
1029            BindingEntry::with_default("settings", json!({"debug": false})),
1030        );
1031
1032        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1033        assert_eq!(bindings.get("cfg"), Some(&json!({"debug": false})));
1034    }
1035
1036    #[test]
1037    fn resolve_with_default_array() {
1038        let store = RunContext::new();
1039
1040        let mut spec = BindingSpec::default();
1041        spec.insert(
1042            "tags".to_string(),
1043            BindingEntry::with_default("meta.tags", json!(["default"])),
1044        );
1045
1046        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1047        assert_eq!(bindings.get("tags"), Some(&json!(["default"])));
1048    }
1049
1050    // ═══════════════════════════════════════════════════════════════
1051    // Error cases
1052    // ═══════════════════════════════════════════════════════════════
1053
1054    #[test]
1055    fn resolve_path_not_found_error() {
1056        let store = RunContext::new();
1057
1058        let mut spec = BindingSpec::default();
1059        spec.insert("x".to_string(), BindingEntry::new("missing.path"));
1060
1061        let result = ResolvedBindings::from_binding_spec(Some(&spec), &store);
1062        assert!(result.is_err());
1063        assert!(result.unwrap_err().to_string().contains("NIKA-052"));
1064    }
1065
1066    #[test]
1067    fn resolve_null_strict_error() {
1068        let store = RunContext::new();
1069        store.insert(
1070            Arc::from("weather"),
1071            TaskResult::success(json!({"summary": null}), Duration::from_secs(1)),
1072        );
1073
1074        let mut spec = BindingSpec::default();
1075        spec.insert("forecast".to_string(), BindingEntry::new("weather.summary"));
1076
1077        let result = ResolvedBindings::from_binding_spec(Some(&spec), &store);
1078        assert!(result.is_err());
1079        assert!(result.unwrap_err().to_string().contains("NIKA-072"));
1080    }
1081
1082    // ═══════════════════════════════════════════════════════════════
1083    // JSONPath tests
1084    // ═══════════════════════════════════════════════════════════════
1085
1086    #[test]
1087    fn resolve_jsonpath_array_index() {
1088        let store = RunContext::new();
1089        store.insert(
1090            Arc::from("data"),
1091            TaskResult::success(
1092                json!({"items": [{"name": "first"}, {"name": "second"}]}),
1093                Duration::from_secs(1),
1094            ),
1095        );
1096
1097        let mut spec = BindingSpec::default();
1098        spec.insert("first".to_string(), BindingEntry::new("data.items[0].name"));
1099
1100        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1101        assert_eq!(bindings.get("first"), Some(&json!("first")));
1102    }
1103
1104    // ═══════════════════════════════════════════════════════════════
1105    // split_path() tests
1106    // ═══════════════════════════════════════════════════════════════
1107
1108    #[test]
1109    fn split_path_task_only() {
1110        let (task_id, field_path) = split_path("weather");
1111        assert_eq!(task_id, "weather");
1112        assert_eq!(field_path, None);
1113    }
1114
1115    #[test]
1116    fn split_path_with_field() {
1117        let (task_id, field_path) = split_path("weather.summary");
1118        assert_eq!(task_id, "weather");
1119        assert_eq!(field_path, Some("summary"));
1120    }
1121
1122    #[test]
1123    fn split_path_nested() {
1124        let (task_id, field_path) = split_path("weather.data.temp.celsius");
1125        assert_eq!(task_id, "weather");
1126        assert_eq!(field_path, Some("data.temp.celsius"));
1127    }
1128
1129    // ═══════════════════════════════════════════════════════════════
1130    // to_value() for event logging
1131    // ═══════════════════════════════════════════════════════════════
1132
1133    #[test]
1134    fn to_value_serializes_resolved_inputs() {
1135        let mut bindings = ResolvedBindings::new();
1136        bindings.set("weather", json!("sunny"));
1137        bindings.set("temp", json!(25));
1138        bindings.set("nested", json!({"key": "value"}));
1139
1140        let value = bindings.to_value();
1141
1142        assert!(value.is_object());
1143        assert_eq!(value["weather"], "sunny");
1144        assert_eq!(value["temp"], 25);
1145        assert_eq!(value["nested"]["key"], "value");
1146    }
1147
1148    #[test]
1149    fn to_value_empty_bindings() {
1150        let bindings = ResolvedBindings::new();
1151        let value = bindings.to_value();
1152
1153        assert!(value.is_object());
1154        assert!(value.as_object().unwrap().is_empty());
1155    }
1156
1157    // ═══════════════════════════════════════════════════════════════
1158    // LazyBinding::is_pending() tests
1159    // ═══════════════════════════════════════════════════════════════
1160
1161    #[test]
1162    fn lazy_binding_resolved_not_pending() {
1163        let binding = LazyBinding::Resolved(json!("value"));
1164        assert!(!binding.is_pending());
1165    }
1166
1167    #[test]
1168    fn lazy_binding_pending_is_pending() {
1169        let binding = LazyBinding::Pending {
1170            path: "task.path".to_string(),
1171            default: None,
1172        };
1173        assert!(binding.is_pending());
1174    }
1175
1176    #[test]
1177    fn lazy_binding_pending_with_default_is_pending() {
1178        let binding = LazyBinding::Pending {
1179            path: "task.path".to_string(),
1180            default: Some(json!("fallback")),
1181        };
1182        assert!(binding.is_pending());
1183    }
1184
1185    #[test]
1186    fn lazy_binding_pending_with_entry_is_pending() {
1187        let binding = LazyBinding::PendingWithEntry {
1188            source: BindingPath::parse("$step1.data").unwrap(),
1189            binding_type: BindingType::Any,
1190            default: None,
1191            transform: None,
1192        };
1193        assert!(binding.is_pending());
1194    }
1195
1196    // ═══════════════════════════════════════════════════════════════
1197    // LazyBinding::get_value() tests
1198    // ═══════════════════════════════════════════════════════════════
1199
1200    #[test]
1201    fn lazy_binding_get_value_resolved() {
1202        let binding = LazyBinding::Resolved(json!("resolved"));
1203        assert_eq!(binding.get_value(), Some(&json!("resolved")));
1204    }
1205
1206    #[test]
1207    fn lazy_binding_get_value_pending() {
1208        let binding = LazyBinding::Pending {
1209            path: "task.path".to_string(),
1210            default: None,
1211        };
1212        assert_eq!(binding.get_value(), None);
1213    }
1214
1215    #[test]
1216    fn lazy_binding_get_value_pending_with_entry() {
1217        let binding = LazyBinding::PendingWithEntry {
1218            source: BindingPath::parse("$step1").unwrap(),
1219            binding_type: BindingType::Any,
1220            default: None,
1221            transform: None,
1222        };
1223        assert_eq!(binding.get_value(), None);
1224    }
1225
1226    #[test]
1227    fn lazy_binding_get_value_complex_value() {
1228        let complex = json!({"nested": {"value": 42}, "array": [1, 2, 3]});
1229        let binding = LazyBinding::Resolved(complex.clone());
1230        assert_eq!(binding.get_value(), Some(&complex));
1231    }
1232
1233    // ═══════════════════════════════════════════════════════════════
1234    // ResolvedBindings::new() tests
1235    // ═══════════════════════════════════════════════════════════════
1236
1237    #[test]
1238    fn new_creates_empty_bindings() {
1239        let bindings = ResolvedBindings::new();
1240        assert!(bindings.is_empty());
1241        assert_eq!(bindings.get("anything"), None);
1242    }
1243
1244    #[test]
1245    fn default_creates_empty_bindings() {
1246        let bindings = ResolvedBindings::default();
1247        assert!(bindings.is_empty());
1248    }
1249
1250    // ═══════════════════════════════════════════════════════════════
1251    // ResolvedBindings::set() tests
1252    // ═══════════════════════════════════════════════════════════════
1253
1254    #[test]
1255    fn set_multiple_values() {
1256        let mut bindings = ResolvedBindings::new();
1257        bindings.set("key1", json!("value1"));
1258        bindings.set("key2", json!(42));
1259        bindings.set("key3", json!({"nested": true}));
1260
1261        assert_eq!(bindings.get("key1"), Some(&json!("value1")));
1262        assert_eq!(bindings.get("key2"), Some(&json!(42)));
1263        assert_eq!(bindings.get("key3"), Some(&json!({"nested": true})));
1264    }
1265
1266    #[test]
1267    fn set_overwrites_previous_value() {
1268        let mut bindings = ResolvedBindings::new();
1269        bindings.set("key", json!("old"));
1270        bindings.set("key", json!("new"));
1271
1272        assert_eq!(bindings.get("key"), Some(&json!("new")));
1273    }
1274
1275    #[test]
1276    fn set_with_string_into() {
1277        let mut bindings = ResolvedBindings::new();
1278        bindings.set("literal", json!("value"));
1279        assert_eq!(bindings.get("literal"), Some(&json!("value")));
1280    }
1281
1282    #[test]
1283    fn set_null_value() {
1284        let mut bindings = ResolvedBindings::new();
1285        bindings.set("nullable", json!(null));
1286        assert_eq!(bindings.get("nullable"), Some(&json!(null)));
1287    }
1288
1289    #[test]
1290    fn set_array_value() {
1291        let mut bindings = ResolvedBindings::new();
1292        let arr = json!([1, 2, 3, "mixed", {"obj": true}]);
1293        bindings.set("array", arr.clone());
1294        assert_eq!(bindings.get("array"), Some(&arr));
1295    }
1296
1297    // ═══════════════════════════════════════════════════════════════
1298    // ResolvedBindings::get() tests
1299    // ═══════════════════════════════════════════════════════════════
1300
1301    #[test]
1302    fn get_nonexistent_returns_none() {
1303        let bindings = ResolvedBindings::new();
1304        assert_eq!(bindings.get("nonexistent"), None);
1305    }
1306
1307    #[test]
1308    fn get_does_not_resolve_lazy() {
1309        let store = RunContext::new();
1310        store.insert(
1311            Arc::from("task"),
1312            TaskResult::success(json!({"value": "result"}), Duration::from_secs(1)),
1313        );
1314
1315        let mut spec = BindingSpec::default();
1316        spec.insert(
1317            "lazy_bind".to_string(),
1318            BindingEntry::lazy_with_default("task.value", json!("default")),
1319        );
1320
1321        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1322        // get() should NOT resolve lazy bindings
1323        assert_eq!(bindings.get("lazy_bind"), None);
1324    }
1325
1326    // ═══════════════════════════════════════════════════════════════
1327    // ResolvedBindings::get_resolved() tests
1328    // ═══════════════════════════════════════════════════════════════
1329
1330    #[test]
1331    fn get_resolved_eager_binding() {
1332        let store = RunContext::new();
1333        store.insert(
1334            Arc::from("task"),
1335            TaskResult::success(json!({"value": "result"}), Duration::from_secs(1)),
1336        );
1337
1338        let mut spec = BindingSpec::default();
1339        spec.insert("eager".to_string(), BindingEntry::new("task.value"));
1340
1341        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1342        let result = bindings.get_resolved("eager", &store).unwrap();
1343        assert_eq!(result, json!("result"));
1344    }
1345
1346    #[test]
1347    fn get_resolved_lazy_binding() {
1348        let store = RunContext::new();
1349        store.insert(
1350            Arc::from("task"),
1351            TaskResult::success(json!({"value": "lazy_result"}), Duration::from_secs(1)),
1352        );
1353
1354        let mut spec = BindingSpec::default();
1355        spec.insert("lazy".to_string(), BindingEntry::new_lazy("task.value"));
1356
1357        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1358        let result = bindings.get_resolved("lazy", &store).unwrap();
1359        assert_eq!(result, json!("lazy_result"));
1360    }
1361
1362    #[test]
1363    fn get_resolved_nonexistent_binding() {
1364        let store = RunContext::new();
1365        let bindings = ResolvedBindings::new();
1366        let result = bindings.get_resolved("missing", &store);
1367        assert!(result.is_err());
1368        assert!(result.unwrap_err().to_string().contains("NIKA-042")); // BindingNotFound
1369    }
1370
1371    #[test]
1372    fn get_resolved_lazy_with_default() {
1373        let store = RunContext::new();
1374        // No task in store - should use default
1375
1376        let mut spec = BindingSpec::default();
1377        spec.insert(
1378            "lazy_default".to_string(),
1379            BindingEntry::lazy_with_default("missing.path", json!("fallback")),
1380        );
1381
1382        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1383        let result = bindings.get_resolved("lazy_default", &store).unwrap();
1384        assert_eq!(result, json!("fallback"));
1385    }
1386
1387    #[test]
1388    fn get_resolved_re_resolves_on_each_call() {
1389        let store = RunContext::new();
1390        store.insert(
1391            Arc::from("task"),
1392            TaskResult::success(json!({"counter": 1}), Duration::from_secs(1)),
1393        );
1394
1395        let mut spec = BindingSpec::default();
1396        spec.insert("lazy".to_string(), BindingEntry::new_lazy("task.counter"));
1397
1398        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1399
1400        // First call
1401        let result1 = bindings.get_resolved("lazy", &store).unwrap();
1402        assert_eq!(result1, json!(1));
1403
1404        // Update store
1405        store.insert(
1406            Arc::from("task"),
1407            TaskResult::success(json!({"counter": 2}), Duration::from_secs(1)),
1408        );
1409
1410        // Second call - should reflect new value (lazy bindings don't cache)
1411        let result2 = bindings.get_resolved("lazy", &store).unwrap();
1412        assert_eq!(result2, json!(2));
1413    }
1414
1415    // ═══════════════════════════════════════════════════════════════
1416    // ResolvedBindings::is_lazy() tests
1417    // ═══════════════════════════════════════════════════════════════
1418
1419    #[test]
1420    fn is_lazy_for_eager_binding() {
1421        let store = RunContext::new();
1422        store.insert(
1423            Arc::from("task"),
1424            TaskResult::success(json!({"value": "test"}), Duration::from_secs(1)),
1425        );
1426
1427        let mut spec = BindingSpec::default();
1428        spec.insert("eager".to_string(), BindingEntry::new("task.value"));
1429
1430        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1431        assert!(!bindings.is_lazy("eager"));
1432    }
1433
1434    #[test]
1435    fn is_lazy_for_lazy_binding() {
1436        let store = RunContext::new();
1437        let mut spec = BindingSpec::default();
1438        spec.insert("lazy".to_string(), BindingEntry::new_lazy("task.value"));
1439
1440        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1441        assert!(bindings.is_lazy("lazy"));
1442    }
1443
1444    #[test]
1445    fn is_lazy_for_nonexistent_binding() {
1446        let bindings = ResolvedBindings::new();
1447        assert!(!bindings.is_lazy("missing"));
1448    }
1449
1450    #[test]
1451    fn is_lazy_after_resolution() {
1452        let store = RunContext::new();
1453        store.insert(
1454            Arc::from("task"),
1455            TaskResult::success(json!({"value": "result"}), Duration::from_secs(1)),
1456        );
1457
1458        let mut spec = BindingSpec::default();
1459        spec.insert("lazy".to_string(), BindingEntry::new_lazy("task.value"));
1460
1461        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1462        // Even after calling get_resolved(), the binding is still marked as lazy
1463        let _ = bindings.get_resolved("lazy", &store);
1464        assert!(bindings.is_lazy("lazy"));
1465    }
1466
1467    // ═══════════════════════════════════════════════════════════════
1468    // ResolvedBindings::iter() tests
1469    // ═══════════════════════════════════════════════════════════════
1470
1471    #[test]
1472    fn iter_empty_bindings() {
1473        let bindings = ResolvedBindings::new();
1474        let count = bindings.iter().count();
1475        assert_eq!(count, 0);
1476    }
1477
1478    #[test]
1479    fn iter_only_resolved_bindings() {
1480        let store = RunContext::new();
1481        store.insert(
1482            Arc::from("task"),
1483            TaskResult::success(json!({"value": "result"}), Duration::from_secs(1)),
1484        );
1485
1486        let mut spec = BindingSpec::default();
1487        spec.insert("eager".to_string(), BindingEntry::new("task.value"));
1488        spec.insert("lazy".to_string(), BindingEntry::new_lazy("task.value"));
1489
1490        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1491
1492        // iter() should only return eager bindings, not lazy ones
1493        let items: Vec<_> = bindings.iter().collect();
1494        assert_eq!(items.len(), 1);
1495        assert_eq!(items[0].0, "eager");
1496        assert_eq!(items[0].1, &json!("result"));
1497    }
1498
1499    #[test]
1500    fn iter_multiple_resolved_bindings() {
1501        let mut bindings = ResolvedBindings::new();
1502        bindings.set("first", json!(1));
1503        bindings.set("second", json!(2));
1504        bindings.set("third", json!(3));
1505
1506        let items: Vec<_> = bindings.iter().collect();
1507        assert_eq!(items.len(), 3);
1508
1509        // Check all items are present (order may vary due to FxHashMap)
1510        let aliases: Vec<_> = items.iter().map(|(alias, _)| *alias).collect();
1511        assert!(aliases.contains(&"first"));
1512        assert!(aliases.contains(&"second"));
1513        assert!(aliases.contains(&"third"));
1514    }
1515
1516    #[test]
1517    fn iter_with_various_value_types() {
1518        let mut bindings = ResolvedBindings::new();
1519        bindings.set("str", json!("text"));
1520        bindings.set("num", json!(42));
1521        bindings.set("obj", json!({"key": "value"}));
1522        bindings.set("arr", json!([1, 2, 3]));
1523        bindings.set("bool", json!(true));
1524
1525        let items: Vec<_> = bindings.iter().collect();
1526        assert_eq!(items.len(), 5);
1527
1528        // Verify all values are accessible
1529        for (alias, value) in &items {
1530            match *alias {
1531                "str" => assert_eq!(*value, &json!("text")),
1532                "num" => assert_eq!(*value, &json!(42)),
1533                "obj" => assert_eq!(*value, &json!({"key": "value"})),
1534                "arr" => assert_eq!(*value, &json!([1, 2, 3])),
1535                "bool" => assert_eq!(*value, &json!(true)),
1536                _ => panic!("unexpected alias: {}", alias),
1537            }
1538        }
1539    }
1540
1541    // ═══════════════════════════════════════════════════════════════
1542    // to_value() with lazy bindings
1543    // ═══════════════════════════════════════════════════════════════
1544
1545    #[test]
1546    fn to_value_with_lazy_bindings() {
1547        let mut bindings = ResolvedBindings::new();
1548        bindings.set("eager", json!("eager_value"));
1549
1550        // Insert old-style lazy binding manually
1551        bindings.bindings.insert(
1552            "lazy".to_string(),
1553            LazyBinding::Pending {
1554                path: "task.path".to_string(),
1555                default: Some(json!("lazy_default")),
1556            },
1557        );
1558
1559        let value = bindings.to_value();
1560        assert!(value.is_object());
1561
1562        let obj = value.as_object().unwrap();
1563        assert_eq!(obj["eager"], json!("eager_value"));
1564
1565        // Lazy bindings are represented as {__lazy__: true, path: "..."}
1566        let lazy_marker = &obj["lazy"];
1567        assert!(lazy_marker.is_object());
1568        assert_eq!(lazy_marker["__lazy__"], true);
1569        assert_eq!(lazy_marker["path"], "task.path");
1570    }
1571
1572    #[test]
1573    fn to_value_with_pending_with_entry() {
1574        let mut bindings = ResolvedBindings::new();
1575        bindings.set("eager", json!("eager_value"));
1576
1577        // Insert new-style lazy binding
1578        bindings.bindings.insert(
1579            "lazy_new".to_string(),
1580            LazyBinding::PendingWithEntry {
1581                source: BindingPath::parse("$step1.data").unwrap(),
1582                binding_type: BindingType::Object,
1583                default: None,
1584                transform: None,
1585            },
1586        );
1587
1588        let value = bindings.to_value();
1589        let obj = value.as_object().unwrap();
1590
1591        let lazy_marker = &obj["lazy_new"];
1592        assert_eq!(lazy_marker["__lazy__"], true);
1593        assert_eq!(lazy_marker["path"], "$step1.data");
1594    }
1595
1596    // ═══════════════════════════════════════════════════════════════
1597    // Error handling in from_binding_spec()
1598    // ═══════════════════════════════════════════════════════════════
1599
1600    #[test]
1601    fn from_binding_spec_eager_missing_path() {
1602        let store = RunContext::new();
1603        let mut spec = BindingSpec::default();
1604        spec.insert("x".to_string(), BindingEntry::new("nonexistent.path"));
1605
1606        let result = ResolvedBindings::from_binding_spec(Some(&spec), &store);
1607        assert!(result.is_err());
1608    }
1609
1610    #[test]
1611    fn from_binding_spec_lazy_does_not_fail_on_missing() {
1612        let store = RunContext::new();
1613        let mut spec = BindingSpec::default();
1614        spec.insert("x".to_string(), BindingEntry::new_lazy("nonexistent.path"));
1615
1616        // Lazy bindings don't fail during from_binding_spec - they fail on get_resolved()
1617        let result = ResolvedBindings::from_binding_spec(Some(&spec), &store);
1618        assert!(result.is_ok());
1619    }
1620
1621    #[test]
1622    fn from_binding_spec_preserves_all_entries() {
1623        let store = RunContext::new();
1624        store.insert(
1625            Arc::from("task1"),
1626            TaskResult::success(json!({"a": 1}), Duration::from_secs(1)),
1627        );
1628        store.insert(
1629            Arc::from("task2"),
1630            TaskResult::success(json!({"b": 2}), Duration::from_secs(1)),
1631        );
1632
1633        let mut spec = BindingSpec::default();
1634        spec.insert("binding1".to_string(), BindingEntry::new("task1.a"));
1635        spec.insert("binding2".to_string(), BindingEntry::new_lazy("task2.b"));
1636
1637        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1638
1639        // Both bindings should exist
1640        assert_eq!(bindings.get("binding1"), Some(&json!(1)));
1641        assert!(bindings.is_lazy("binding2"));
1642    }
1643
1644    // ═══════════════════════════════════════════════════════════════
1645    // Mixed eager and lazy bindings
1646    // ═══════════════════════════════════════════════════════════════
1647
1648    #[test]
1649    fn mixed_eager_and_lazy_workflow() {
1650        let store = RunContext::new();
1651        store.insert(
1652            Arc::from("quick"),
1653            TaskResult::success(json!({"result": "fast"}), Duration::from_secs(1)),
1654        );
1655        store.insert(
1656            Arc::from("slow"),
1657            TaskResult::success(json!({"result": "slow_value"}), Duration::from_secs(5)),
1658        );
1659
1660        let mut spec = BindingSpec::default();
1661        spec.insert("quick_bind".to_string(), BindingEntry::new("quick.result"));
1662        spec.insert(
1663            "slow_bind".to_string(),
1664            BindingEntry::new_lazy("slow.result"),
1665        );
1666
1667        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1668
1669        // Eager should be available immediately
1670        assert_eq!(bindings.get("quick_bind"), Some(&json!("fast")));
1671
1672        // Lazy should still be pending
1673        assert!(bindings.is_lazy("slow_bind"));
1674        assert_eq!(bindings.get("slow_bind"), None);
1675
1676        // But can be resolved on demand
1677        let resolved = bindings.get_resolved("slow_bind", &store).unwrap();
1678        assert_eq!(resolved, json!("slow_value"));
1679    }
1680
1681    // ═══════════════════════════════════════════════════════════════
1682    // Edge cases with special values
1683    // ═══════════════════════════════════════════════════════════════
1684
1685    #[test]
1686    fn binding_with_empty_string() {
1687        let mut bindings = ResolvedBindings::new();
1688        bindings.set("empty", json!(""));
1689        assert_eq!(bindings.get("empty"), Some(&json!("")));
1690    }
1691
1692    #[test]
1693    fn binding_with_zero() {
1694        let mut bindings = ResolvedBindings::new();
1695        bindings.set("zero", json!(0));
1696        assert_eq!(bindings.get("zero"), Some(&json!(0)));
1697    }
1698
1699    #[test]
1700    fn binding_with_false() {
1701        let mut bindings = ResolvedBindings::new();
1702        bindings.set("falsy", json!(false));
1703        assert_eq!(bindings.get("falsy"), Some(&json!(false)));
1704    }
1705
1706    #[test]
1707    fn binding_with_empty_array() {
1708        let mut bindings = ResolvedBindings::new();
1709        bindings.set("empty_arr", json!([]));
1710        assert_eq!(bindings.get("empty_arr"), Some(&json!([])));
1711    }
1712
1713    #[test]
1714    fn binding_with_empty_object() {
1715        let mut bindings = ResolvedBindings::new();
1716        bindings.set("empty_obj", json!({}));
1717        assert_eq!(bindings.get("empty_obj"), Some(&json!({})));
1718    }
1719
1720    // ═══════════════════════════════════════════════════════════════
1721    // inputs.* binding support
1722    // ═══════════════════════════════════════════════════════════════
1723
1724    #[test]
1725    fn resolve_inputs_simple() {
1726        use rustc_hash::FxHashMap;
1727
1728        let store = RunContext::new();
1729
1730        let mut inputs = FxHashMap::default();
1731        inputs.insert(
1732            "topic".to_string(),
1733            json!({
1734                "type": "string",
1735                "default": "AI trends 2025"
1736            }),
1737        );
1738        store.set_inputs(inputs);
1739
1740        let mut spec = BindingSpec::default();
1741        spec.insert("topic_val".to_string(), BindingEntry::new("inputs.topic"));
1742
1743        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1744        assert_eq!(bindings.get("topic_val"), Some(&json!("AI trends 2025")));
1745    }
1746
1747    #[test]
1748    fn resolve_inputs_nested_field() {
1749        use rustc_hash::FxHashMap;
1750
1751        let store = RunContext::new();
1752
1753        let mut inputs = FxHashMap::default();
1754        inputs.insert(
1755            "config".to_string(),
1756            json!({
1757                "type": "object",
1758                "default": {
1759                    "theme": "dark",
1760                    "version": 2,
1761                    "nested": {
1762                        "deep": "value"
1763                    }
1764                }
1765            }),
1766        );
1767        store.set_inputs(inputs);
1768
1769        let mut spec = BindingSpec::default();
1770        spec.insert(
1771            "theme".to_string(),
1772            BindingEntry::new("inputs.config.theme"),
1773        );
1774        spec.insert(
1775            "deep".to_string(),
1776            BindingEntry::new("inputs.config.nested.deep"),
1777        );
1778
1779        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1780        assert_eq!(bindings.get("theme"), Some(&json!("dark")));
1781        assert_eq!(bindings.get("deep"), Some(&json!("value")));
1782    }
1783
1784    #[test]
1785    fn resolve_inputs_with_default_on_missing() {
1786        let store = RunContext::new();
1787
1788        let mut spec = BindingSpec::default();
1789        spec.insert(
1790            "fallback".to_string(),
1791            BindingEntry::with_default("inputs.missing", json!("default_value")),
1792        );
1793
1794        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1795        assert_eq!(bindings.get("fallback"), Some(&json!("default_value")));
1796    }
1797
1798    #[test]
1799    fn resolve_inputs_missing_no_default() {
1800        let store = RunContext::new();
1801
1802        let mut spec = BindingSpec::default();
1803        spec.insert("missing".to_string(), BindingEntry::new("inputs.missing"));
1804
1805        let result = ResolvedBindings::from_binding_spec(Some(&spec), &store);
1806        assert!(result.is_err());
1807        assert!(result.unwrap_err().to_string().contains("NIKA-052")); // PathNotFound
1808    }
1809
1810    #[test]
1811    fn resolve_inputs_lazy_binding() {
1812        use rustc_hash::FxHashMap;
1813
1814        let store = RunContext::new();
1815
1816        let mut inputs = FxHashMap::default();
1817        inputs.insert(
1818            "lazy_input".to_string(),
1819            json!({
1820                "type": "string",
1821                "default": "lazy_value"
1822            }),
1823        );
1824        store.set_inputs(inputs);
1825
1826        let mut spec = BindingSpec::default();
1827        spec.insert(
1828            "lazy_alias".to_string(),
1829            BindingEntry::new_lazy("inputs.lazy_input"),
1830        );
1831
1832        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1833
1834        assert!(bindings.is_lazy("lazy_alias"));
1835        assert_eq!(bindings.get("lazy_alias"), None);
1836
1837        let resolved = bindings.get_resolved("lazy_alias", &store).unwrap();
1838        assert_eq!(resolved, json!("lazy_value"));
1839    }
1840
1841    #[test]
1842    fn resolve_inputs_mixed_with_task_outputs() {
1843        use rustc_hash::FxHashMap;
1844
1845        let store = RunContext::new();
1846
1847        let mut inputs = FxHashMap::default();
1848        inputs.insert(
1849            "topic".to_string(),
1850            json!({
1851                "type": "string",
1852                "default": "AI"
1853            }),
1854        );
1855        store.set_inputs(inputs);
1856
1857        store.insert(
1858            Arc::from("step1"),
1859            TaskResult::success(json!({"result": "generated"}), Duration::from_secs(1)),
1860        );
1861
1862        let mut spec = BindingSpec::default();
1863        spec.insert("from_input".to_string(), BindingEntry::new("inputs.topic"));
1864        spec.insert("from_task".to_string(), BindingEntry::new("step1.result"));
1865
1866        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1867
1868        assert_eq!(bindings.get("from_input"), Some(&json!("AI")));
1869        assert_eq!(bindings.get("from_task"), Some(&json!("generated")));
1870    }
1871
1872    #[test]
1873    fn resolve_inputs_array_value() {
1874        use rustc_hash::FxHashMap;
1875
1876        let store = RunContext::new();
1877
1878        let mut inputs = FxHashMap::default();
1879        inputs.insert(
1880            "items".to_string(),
1881            json!({
1882                "type": "array",
1883                "default": ["a", "b", "c"]
1884            }),
1885        );
1886        store.set_inputs(inputs);
1887
1888        let mut spec = BindingSpec::default();
1889        spec.insert("all_items".to_string(), BindingEntry::new("inputs.items"));
1890
1891        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
1892        assert_eq!(bindings.get("all_items"), Some(&json!(["a", "b", "c"])));
1893    }
1894
1895    // ═══════════════════════════════════════════════════════════════
1896    // from_with_spec tests (with: block)
1897    // ═══════════════════════════════════════════════════════════════
1898
1899    #[test]
1900    fn with_spec_task_simple() {
1901        let store = RunContext::new();
1902        store.insert(
1903            Arc::from("step1"),
1904            TaskResult::success(json!({"title": "Hello"}), Duration::from_secs(1)),
1905        );
1906
1907        let mut spec = WithSpec::default();
1908        spec.insert(
1909            "title".to_string(),
1910            WithEntry::simple(BindingPath::parse("$step1.title").unwrap()),
1911        );
1912
1913        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
1914        assert_eq!(bindings.get("title"), Some(&json!("Hello")));
1915    }
1916
1917    #[test]
1918    fn with_spec_task_entire_output() {
1919        let store = RunContext::new();
1920        store.insert(
1921            Arc::from("step1"),
1922            TaskResult::success(json!({"a": 1, "b": 2}), Duration::from_secs(1)),
1923        );
1924
1925        let mut spec = WithSpec::default();
1926        spec.insert(
1927            "data".to_string(),
1928            WithEntry::simple(BindingPath::parse("$step1").unwrap()),
1929        );
1930
1931        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
1932        assert_eq!(bindings.get("data"), Some(&json!({"a": 1, "b": 2})));
1933    }
1934
1935    #[test]
1936    fn with_spec_task_nested_path() {
1937        let store = RunContext::new();
1938        store.insert(
1939            Arc::from("step1"),
1940            TaskResult::success(
1941                json!({"data": {"items": [{"name": "first"}]}}),
1942                Duration::from_secs(1),
1943            ),
1944        );
1945
1946        let mut spec = WithSpec::default();
1947        spec.insert(
1948            "first_name".to_string(),
1949            WithEntry::simple(BindingPath::parse("$step1.data.items[0].name").unwrap()),
1950        );
1951
1952        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
1953        assert_eq!(bindings.get("first_name"), Some(&json!("first")));
1954    }
1955
1956    #[test]
1957    fn with_spec_task_with_default_on_missing() {
1958        let store = RunContext::new();
1959        // No step1 task in store
1960
1961        let mut spec = WithSpec::default();
1962        spec.insert(
1963            "result".to_string(),
1964            WithEntry::with_default(
1965                BindingPath::parse("$step1.data").unwrap(),
1966                json!("fallback"),
1967            ),
1968        );
1969
1970        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
1971        assert_eq!(bindings.get("result"), Some(&json!("fallback")));
1972    }
1973
1974    #[test]
1975    fn with_spec_task_with_default_on_null() {
1976        let store = RunContext::new();
1977        store.insert(
1978            Arc::from("step1"),
1979            TaskResult::success(json!({"data": null}), Duration::from_secs(1)),
1980        );
1981
1982        let mut spec = WithSpec::default();
1983        spec.insert(
1984            "result".to_string(),
1985            WithEntry::with_default(
1986                BindingPath::parse("$step1.data").unwrap(),
1987                json!("fallback"),
1988            ),
1989        );
1990
1991        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
1992        assert_eq!(bindings.get("result"), Some(&json!("fallback")));
1993    }
1994
1995    #[test]
1996    fn with_spec_task_missing_no_default_error() {
1997        let store = RunContext::new();
1998
1999        let mut spec = WithSpec::default();
2000        spec.insert(
2001            "result".to_string(),
2002            WithEntry::simple(BindingPath::parse("$step1.data").unwrap()),
2003        );
2004
2005        let result = ResolvedBindings::from_with_spec(Some(&spec), &store);
2006        assert!(result.is_err());
2007        let err_str = result.unwrap_err().to_string();
2008        assert!(err_str.contains("NIKA-052")); // PathNotFound
2009    }
2010
2011    #[test]
2012    fn with_spec_task_null_no_default_error() {
2013        let store = RunContext::new();
2014        store.insert(
2015            Arc::from("step1"),
2016            TaskResult::success(json!({"data": null}), Duration::from_secs(1)),
2017        );
2018
2019        let mut spec = WithSpec::default();
2020        spec.insert(
2021            "result".to_string(),
2022            WithEntry::simple(BindingPath::parse("$step1.data").unwrap()),
2023        );
2024
2025        let result = ResolvedBindings::from_with_spec(Some(&spec), &store);
2026        assert!(result.is_err());
2027        let err_str = result.unwrap_err().to_string();
2028        assert!(err_str.contains("NIKA-072")); // NullValue
2029    }
2030
2031    // ═══════════════════════════════════════════════════════════════
2032    // WithSpec: Input source tests
2033    // ═══════════════════════════════════════════════════════════════
2034
2035    #[test]
2036    fn with_spec_input_simple() {
2037        use rustc_hash::FxHashMap;
2038
2039        let store = RunContext::new();
2040        let mut inputs = FxHashMap::default();
2041        inputs.insert(
2042            "topic".to_string(),
2043            json!({"type": "string", "default": "AI trends"}),
2044        );
2045        store.set_inputs(inputs);
2046
2047        let mut spec = WithSpec::default();
2048        spec.insert(
2049            "topic".to_string(),
2050            WithEntry::simple(BindingPath::parse("$inputs.topic").unwrap()),
2051        );
2052
2053        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2054        assert_eq!(bindings.get("topic"), Some(&json!("AI trends")));
2055    }
2056
2057    #[test]
2058    fn with_spec_input_nested() {
2059        use rustc_hash::FxHashMap;
2060
2061        let store = RunContext::new();
2062        let mut inputs = FxHashMap::default();
2063        inputs.insert(
2064            "config".to_string(),
2065            json!({"type": "object", "default": {"theme": "dark", "nested": {"deep": "val"}}}),
2066        );
2067        store.set_inputs(inputs);
2068
2069        let mut spec = WithSpec::default();
2070        spec.insert(
2071            "theme".to_string(),
2072            WithEntry::simple(BindingPath::parse("$inputs.config.theme").unwrap()),
2073        );
2074        spec.insert(
2075            "deep".to_string(),
2076            WithEntry::simple(BindingPath::parse("$inputs.config.nested.deep").unwrap()),
2077        );
2078
2079        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2080        assert_eq!(bindings.get("theme"), Some(&json!("dark")));
2081        assert_eq!(bindings.get("deep"), Some(&json!("val")));
2082    }
2083
2084    #[test]
2085    fn with_spec_input_missing_with_default() {
2086        let store = RunContext::new();
2087        // No inputs set
2088
2089        let mut spec = WithSpec::default();
2090        spec.insert(
2091            "fallback".to_string(),
2092            WithEntry::with_default(
2093                BindingPath::parse("$inputs.missing").unwrap(),
2094                json!("default_val"),
2095            ),
2096        );
2097
2098        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2099        assert_eq!(bindings.get("fallback"), Some(&json!("default_val")));
2100    }
2101
2102    // ═══════════════════════════════════════════════════════════════
2103    // WithSpec: Env source tests
2104    // ═══════════════════════════════════════════════════════════════
2105
2106    #[test]
2107    fn with_spec_env_existing_var() {
2108        // Use a known env var
2109        std::env::set_var("NIKA_TEST_VAR_8A", "test_value_8a");
2110
2111        let store = RunContext::new();
2112        let mut spec = WithSpec::default();
2113        spec.insert(
2114            "my_var".to_string(),
2115            WithEntry::simple(BindingPath::parse("$env.NIKA_TEST_VAR_8A").unwrap()),
2116        );
2117
2118        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2119        assert_eq!(bindings.get("my_var"), Some(&json!("test_value_8a")));
2120
2121        std::env::remove_var("NIKA_TEST_VAR_8A");
2122    }
2123
2124    #[test]
2125    fn with_spec_env_missing_with_default() {
2126        let store = RunContext::new();
2127        let mut spec = WithSpec::default();
2128        spec.insert(
2129            "missing_env".to_string(),
2130            WithEntry::with_default(
2131                BindingPath::parse("$env.NIKA_NONEXISTENT_VAR_XYZ").unwrap(),
2132                json!("fallback_env"),
2133            ),
2134        );
2135
2136        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2137        assert_eq!(bindings.get("missing_env"), Some(&json!("fallback_env")));
2138    }
2139
2140    #[test]
2141    fn with_spec_env_missing_no_default_error() {
2142        let store = RunContext::new();
2143        let mut spec = WithSpec::default();
2144        spec.insert(
2145            "missing".to_string(),
2146            WithEntry::simple(BindingPath::parse("$env.NIKA_NONEXISTENT_VAR_ABC").unwrap()),
2147        );
2148
2149        let result = ResolvedBindings::from_with_spec(Some(&spec), &store);
2150        assert!(result.is_err());
2151    }
2152
2153    // ═══════════════════════════════════════════════════════════════
2154    // WithSpec: Context source tests
2155    // ═══════════════════════════════════════════════════════════════
2156
2157    #[test]
2158    fn with_spec_context_file() {
2159        use crate::store::LoadedContext;
2160        let store = RunContext::new();
2161        let mut ctx = LoadedContext::new();
2162        ctx.files
2163            .insert("brand".to_string(), json!("Brand Guidelines v2"));
2164        store.set_context(ctx);
2165
2166        let mut spec = WithSpec::default();
2167        spec.insert(
2168            "brand".to_string(),
2169            WithEntry::simple(BindingPath::parse("$context.files.brand").unwrap()),
2170        );
2171
2172        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2173        assert_eq!(bindings.get("brand"), Some(&json!("Brand Guidelines v2")));
2174    }
2175
2176    #[test]
2177    fn with_spec_context_session() {
2178        use crate::store::LoadedContext;
2179        let store = RunContext::new();
2180        let mut ctx = LoadedContext::new();
2181        ctx.session = Some(json!({"last_run": "2025-01-01"}));
2182        store.set_context(ctx);
2183
2184        let mut spec = WithSpec::default();
2185        spec.insert(
2186            "session".to_string(),
2187            WithEntry::simple(BindingPath::parse("$context.session").unwrap()),
2188        );
2189
2190        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2191        assert_eq!(
2192            bindings.get("session"),
2193            Some(&json!({"last_run": "2025-01-01"}))
2194        );
2195    }
2196
2197    #[test]
2198    fn with_spec_context_missing_with_default() {
2199        let store = RunContext::new();
2200        // No context files loaded
2201
2202        let mut spec = WithSpec::default();
2203        spec.insert(
2204            "brand".to_string(),
2205            WithEntry::with_default(
2206                BindingPath::parse("$context.files.brand").unwrap(),
2207                json!("no brand"),
2208            ),
2209        );
2210
2211        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2212        assert_eq!(bindings.get("brand"), Some(&json!("no brand")));
2213    }
2214
2215    // ═══════════════════════════════════════════════════════════════
2216    // WithSpec: Lazy bindings
2217    // ═══════════════════════════════════════════════════════════════
2218
2219    #[test]
2220    fn with_spec_lazy_does_not_fail_on_missing() {
2221        let store = RunContext::new();
2222
2223        let mut spec = WithSpec::default();
2224        let mut entry = WithEntry::simple(BindingPath::parse("$step1.data").unwrap());
2225        entry.lazy = true;
2226        spec.insert("lazy_val".to_string(), entry);
2227
2228        let result = ResolvedBindings::from_with_spec(Some(&spec), &store);
2229        assert!(result.is_ok());
2230        let bindings = result.unwrap();
2231        assert!(bindings.is_lazy("lazy_val"));
2232        assert_eq!(bindings.get("lazy_val"), None);
2233    }
2234
2235    #[test]
2236    fn with_spec_lazy_resolve_on_demand() {
2237        let store = RunContext::new();
2238        store.insert(
2239            Arc::from("step1"),
2240            TaskResult::success(json!({"data": "deferred"}), Duration::from_secs(1)),
2241        );
2242
2243        let mut spec = WithSpec::default();
2244        let mut entry = WithEntry::simple(BindingPath::parse("$step1.data").unwrap());
2245        entry.lazy = true;
2246        spec.insert("lazy_val".to_string(), entry);
2247
2248        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2249        assert!(bindings.is_lazy("lazy_val"));
2250
2251        let resolved = bindings.get_resolved("lazy_val", &store).unwrap();
2252        assert_eq!(resolved, json!("deferred"));
2253    }
2254
2255    #[test]
2256    fn with_spec_lazy_re_resolves() {
2257        let store = RunContext::new();
2258        store.insert(
2259            Arc::from("step1"),
2260            TaskResult::success(json!({"counter": 1}), Duration::from_secs(1)),
2261        );
2262
2263        let mut spec = WithSpec::default();
2264        let mut entry = WithEntry::simple(BindingPath::parse("$step1.counter").unwrap());
2265        entry.lazy = true;
2266        spec.insert("counter".to_string(), entry);
2267
2268        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2269
2270        let v1 = bindings.get_resolved("counter", &store).unwrap();
2271        assert_eq!(v1, json!(1));
2272
2273        // Update store
2274        store.insert(
2275            Arc::from("step1"),
2276            TaskResult::success(json!({"counter": 42}), Duration::from_secs(1)),
2277        );
2278
2279        let v2 = bindings.get_resolved("counter", &store).unwrap();
2280        assert_eq!(v2, json!(42));
2281    }
2282
2283    // ═══════════════════════════════════════════════════════════════
2284    // WithSpec: Transform pipeline tests
2285    // ═══════════════════════════════════════════════════════════════
2286
2287    #[test]
2288    fn with_spec_with_transform() {
2289        let store = RunContext::new();
2290        store.insert(
2291            Arc::from("step1"),
2292            TaskResult::success(json!({"name": "  Hello World  "}), Duration::from_secs(1)),
2293        );
2294
2295        let mut spec = WithSpec::default();
2296        let mut entry = WithEntry::simple(BindingPath::parse("$step1.name").unwrap());
2297        entry.transform = Some(TransformExpr::parse("trim | upper").unwrap());
2298        spec.insert("name".to_string(), entry);
2299
2300        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2301        assert_eq!(bindings.get("name"), Some(&json!("HELLO WORLD")));
2302    }
2303
2304    #[test]
2305    fn with_spec_transform_with_default_on_null() {
2306        let store = RunContext::new();
2307        store.insert(
2308            Arc::from("step1"),
2309            TaskResult::success(json!({"name": null}), Duration::from_secs(1)),
2310        );
2311
2312        let mut spec = WithSpec::default();
2313        let mut entry =
2314            WithEntry::with_default(BindingPath::parse("$step1.name").unwrap(), json!("DEFAULT"));
2315        entry.transform = Some(TransformExpr::parse("upper").unwrap());
2316        spec.insert("name".to_string(), entry);
2317
2318        // Null goes through transform pipeline as-is (transform skipped for null),
2319        // then default kicks in
2320        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2321        assert_eq!(bindings.get("name"), Some(&json!("DEFAULT")));
2322    }
2323
2324    #[test]
2325    fn with_spec_transform_chain() {
2326        let store = RunContext::new();
2327        store.insert(
2328            Arc::from("step1"),
2329            TaskResult::success(json!({"items": [3, 1, 4, 1, 5, 9]}), Duration::from_secs(1)),
2330        );
2331
2332        let mut spec = WithSpec::default();
2333        let mut entry = WithEntry::simple(BindingPath::parse("$step1.items").unwrap());
2334        entry.transform = Some(TransformExpr::parse("sort | unique | length").unwrap());
2335        spec.insert("unique_count".to_string(), entry);
2336
2337        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2338        // [3,1,4,1,5,9] → sort → [1,1,3,4,5,9] → unique → [1,3,4,5,9] → length → 5
2339        assert_eq!(bindings.get("unique_count"), Some(&json!(5)));
2340    }
2341
2342    // ═══════════════════════════════════════════════════════════════
2343    // WithSpec: BindingType validation tests
2344    // ═══════════════════════════════════════════════════════════════
2345
2346    #[test]
2347    fn with_spec_type_string_valid() {
2348        let store = RunContext::new();
2349        store.insert(
2350            Arc::from("step1"),
2351            TaskResult::success(json!({"name": "text"}), Duration::from_secs(1)),
2352        );
2353
2354        let mut spec = WithSpec::default();
2355        let mut entry = WithEntry::simple(BindingPath::parse("$step1.name").unwrap());
2356        entry.binding_type = BindingType::String;
2357        spec.insert("name".to_string(), entry);
2358
2359        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2360        assert_eq!(bindings.get("name"), Some(&json!("text")));
2361    }
2362
2363    #[test]
2364    fn with_spec_type_string_invalid() {
2365        let store = RunContext::new();
2366        store.insert(
2367            Arc::from("step1"),
2368            TaskResult::success(json!({"count": 42}), Duration::from_secs(1)),
2369        );
2370
2371        let mut spec = WithSpec::default();
2372        let mut entry = WithEntry::simple(BindingPath::parse("$step1.count").unwrap());
2373        entry.binding_type = BindingType::String;
2374        spec.insert("count".to_string(), entry);
2375
2376        let result = ResolvedBindings::from_with_spec(Some(&spec), &store);
2377        assert!(result.is_err());
2378        let err_str = result.unwrap_err().to_string();
2379        assert!(err_str.contains("NIKA-043")); // BindingTypeMismatch
2380    }
2381
2382    #[test]
2383    fn with_spec_type_array_valid() {
2384        let store = RunContext::new();
2385        store.insert(
2386            Arc::from("step1"),
2387            TaskResult::success(json!({"items": [1, 2, 3]}), Duration::from_secs(1)),
2388        );
2389
2390        let mut spec = WithSpec::default();
2391        let mut entry = WithEntry::simple(BindingPath::parse("$step1.items").unwrap());
2392        entry.binding_type = BindingType::Array;
2393        spec.insert("items".to_string(), entry);
2394
2395        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2396        assert_eq!(bindings.get("items"), Some(&json!([1, 2, 3])));
2397    }
2398
2399    #[test]
2400    fn with_spec_type_any_accepts_all() {
2401        let store = RunContext::new();
2402        store.insert(
2403            Arc::from("step1"),
2404            TaskResult::success(json!({"val": [1, "mixed"]}), Duration::from_secs(1)),
2405        );
2406
2407        let mut spec = WithSpec::default();
2408        let mut entry = WithEntry::simple(BindingPath::parse("$step1.val").unwrap());
2409        entry.binding_type = BindingType::Any;
2410        spec.insert("val".to_string(), entry);
2411
2412        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2413        assert_eq!(bindings.get("val"), Some(&json!([1, "mixed"])));
2414    }
2415
2416    #[test]
2417    fn with_spec_type_object_valid() {
2418        let store = RunContext::new();
2419        store.insert(
2420            Arc::from("step1"),
2421            TaskResult::success(json!({"cfg": {"debug": true}}), Duration::from_secs(1)),
2422        );
2423
2424        let mut spec = WithSpec::default();
2425        let mut entry = WithEntry::simple(BindingPath::parse("$step1.cfg").unwrap());
2426        entry.binding_type = BindingType::Object;
2427        spec.insert("cfg".to_string(), entry);
2428
2429        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2430        assert_eq!(bindings.get("cfg"), Some(&json!({"debug": true})));
2431    }
2432
2433    #[test]
2434    fn with_spec_type_number_valid() {
2435        let store = RunContext::new();
2436        store.insert(
2437            Arc::from("step1"),
2438            TaskResult::success(json!({"temp": 25.5}), Duration::from_secs(1)),
2439        );
2440
2441        let mut spec = WithSpec::default();
2442        let mut entry = WithEntry::simple(BindingPath::parse("$step1.temp").unwrap());
2443        entry.binding_type = BindingType::Number;
2444        spec.insert("temp".to_string(), entry);
2445
2446        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2447        assert_eq!(bindings.get("temp"), Some(&json!(25.5)));
2448    }
2449
2450    #[test]
2451    fn with_spec_type_integer_valid() {
2452        let store = RunContext::new();
2453        store.insert(
2454            Arc::from("step1"),
2455            TaskResult::success(json!({"count": 42}), Duration::from_secs(1)),
2456        );
2457
2458        let mut spec = WithSpec::default();
2459        let mut entry = WithEntry::simple(BindingPath::parse("$step1.count").unwrap());
2460        entry.binding_type = BindingType::Integer;
2461        spec.insert("count".to_string(), entry);
2462
2463        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2464        assert_eq!(bindings.get("count"), Some(&json!(42)));
2465    }
2466
2467    #[test]
2468    fn with_spec_type_integer_rejects_float() {
2469        let store = RunContext::new();
2470        store.insert(
2471            Arc::from("step1"),
2472            TaskResult::success(json!({"val": 3.12}), Duration::from_secs(1)),
2473        );
2474
2475        let mut spec = WithSpec::default();
2476        let mut entry = WithEntry::simple(BindingPath::parse("$step1.val").unwrap());
2477        entry.binding_type = BindingType::Integer;
2478        spec.insert("val".to_string(), entry);
2479
2480        let result = ResolvedBindings::from_with_spec(Some(&spec), &store);
2481        assert!(result.is_err());
2482        assert!(result.unwrap_err().to_string().contains("NIKA-043"));
2483    }
2484
2485    #[test]
2486    fn with_spec_type_boolean_valid() {
2487        let store = RunContext::new();
2488        store.insert(
2489            Arc::from("step1"),
2490            TaskResult::success(json!({"flag": true}), Duration::from_secs(1)),
2491        );
2492
2493        let mut spec = WithSpec::default();
2494        let mut entry = WithEntry::simple(BindingPath::parse("$step1.flag").unwrap());
2495        entry.binding_type = BindingType::Boolean;
2496        spec.insert("flag".to_string(), entry);
2497
2498        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2499        assert_eq!(bindings.get("flag"), Some(&json!(true)));
2500    }
2501
2502    // ═══════════════════════════════════════════════════════════════
2503    // WithSpec: Mixed source types
2504    // ═══════════════════════════════════════════════════════════════
2505
2506    #[test]
2507    fn with_spec_mixed_sources() {
2508        use rustc_hash::FxHashMap;
2509
2510        let store = RunContext::new();
2511
2512        // Task output
2513        store.insert(
2514            Arc::from("step1"),
2515            TaskResult::success(json!({"result": "task_val"}), Duration::from_secs(1)),
2516        );
2517
2518        // Inputs
2519        let mut inputs = FxHashMap::default();
2520        inputs.insert(
2521            "topic".to_string(),
2522            json!({"type": "string", "default": "AI"}),
2523        );
2524        store.set_inputs(inputs);
2525
2526        // Context file
2527        {
2528            use crate::store::LoadedContext;
2529            let mut ctx = LoadedContext::new();
2530            ctx.files.insert("brand".to_string(), json!("Brand Text"));
2531            store.set_context(ctx);
2532        }
2533
2534        // Env
2535        std::env::set_var("NIKA_TEST_MIXED_8A", "env_val");
2536
2537        let mut spec = WithSpec::default();
2538        spec.insert(
2539            "from_task".to_string(),
2540            WithEntry::simple(BindingPath::parse("$step1.result").unwrap()),
2541        );
2542        spec.insert(
2543            "from_input".to_string(),
2544            WithEntry::simple(BindingPath::parse("$inputs.topic").unwrap()),
2545        );
2546        spec.insert(
2547            "from_context".to_string(),
2548            WithEntry::simple(BindingPath::parse("$context.files.brand").unwrap()),
2549        );
2550        spec.insert(
2551            "from_env".to_string(),
2552            WithEntry::simple(BindingPath::parse("$env.NIKA_TEST_MIXED_8A").unwrap()),
2553        );
2554
2555        let bindings = ResolvedBindings::from_with_spec(Some(&spec), &store).unwrap();
2556
2557        assert_eq!(bindings.get("from_task"), Some(&json!("task_val")));
2558        assert_eq!(bindings.get("from_input"), Some(&json!("AI")));
2559        assert_eq!(bindings.get("from_context"), Some(&json!("Brand Text")));
2560        assert_eq!(bindings.get("from_env"), Some(&json!("env_val")));
2561
2562        std::env::remove_var("NIKA_TEST_MIXED_8A");
2563    }
2564
2565    // ═══════════════════════════════════════════════════════════════
2566    // WithSpec: LoopVar error
2567    // ═══════════════════════════════════════════════════════════════
2568
2569    #[test]
2570    fn with_spec_loop_var_errors() {
2571        let store = RunContext::new();
2572
2573        let mut spec = WithSpec::default();
2574        spec.insert(
2575            "item".to_string(),
2576            WithEntry::simple(BindingPath {
2577                source: BindingSource::LoopVar(Arc::from("item")),
2578                segments: vec![],
2579            }),
2580        );
2581
2582        let result = ResolvedBindings::from_with_spec(Some(&spec), &store);
2583        assert!(result.is_err());
2584        let err_str = result.unwrap_err().to_string();
2585        assert!(err_str.contains("loop variable"));
2586    }
2587
2588    // ═══════════════════════════════════════════════════════════════
2589    // navigate_segments tests
2590    // ═══════════════════════════════════════════════════════════════
2591
2592    #[test]
2593    fn navigate_segments_empty() {
2594        let value = json!({"hello": "world"});
2595        let result = navigate_segments(&value, &[]).unwrap();
2596        assert_eq!(result, Some(json!({"hello": "world"})));
2597    }
2598
2599    #[test]
2600    fn navigate_segments_field() {
2601        let value = json!({"name": "Nika"});
2602        let segments = vec![PathSegment::Field(Arc::from("name"))];
2603        let result = navigate_segments(&value, &segments).unwrap();
2604        assert_eq!(result, Some(json!("Nika")));
2605    }
2606
2607    #[test]
2608    fn navigate_segments_deep_field() {
2609        let value = json!({"a": {"b": {"c": 42}}});
2610        let segments = vec![
2611            PathSegment::Field(Arc::from("a")),
2612            PathSegment::Field(Arc::from("b")),
2613            PathSegment::Field(Arc::from("c")),
2614        ];
2615        let result = navigate_segments(&value, &segments).unwrap();
2616        assert_eq!(result, Some(json!(42)));
2617    }
2618
2619    #[test]
2620    fn navigate_segments_array_index() {
2621        let value = json!({"items": ["a", "b", "c"]});
2622        let segments = vec![
2623            PathSegment::Field(Arc::from("items")),
2624            PathSegment::Index(1),
2625        ];
2626        let result = navigate_segments(&value, &segments).unwrap();
2627        assert_eq!(result, Some(json!("b")));
2628    }
2629
2630    #[test]
2631    fn navigate_segments_mixed() {
2632        let value = json!({"data": [{"name": "first"}, {"name": "second"}]});
2633        let segments = vec![
2634            PathSegment::Field(Arc::from("data")),
2635            PathSegment::Index(1),
2636            PathSegment::Field(Arc::from("name")),
2637        ];
2638        let result = navigate_segments(&value, &segments).unwrap();
2639        assert_eq!(result, Some(json!("second")));
2640    }
2641
2642    #[test]
2643    fn navigate_segments_missing_field() {
2644        let value = json!({"a": 1});
2645        let segments = vec![PathSegment::Field(Arc::from("missing"))];
2646        let result = navigate_segments(&value, &segments).unwrap();
2647        assert_eq!(result, None);
2648    }
2649
2650    #[test]
2651    fn navigate_segments_out_of_bounds() {
2652        let value = json!([1, 2, 3]);
2653        let segments = vec![PathSegment::Index(10)];
2654        let result = navigate_segments(&value, &segments).unwrap();
2655        assert_eq!(result, None);
2656    }
2657
2658    #[test]
2659    fn navigate_segments_field_on_non_object() {
2660        let value = json!("string_value");
2661        let segments = vec![PathSegment::Field(Arc::from("field"))];
2662        let result = navigate_segments(&value, &segments).unwrap();
2663        assert_eq!(result, None);
2664    }
2665
2666    #[test]
2667    fn navigate_segments_index_on_non_array() {
2668        let value = json!({"key": "val"});
2669        let segments = vec![PathSegment::Index(0)];
2670        let result = navigate_segments(&value, &segments).unwrap();
2671        assert_eq!(result, None);
2672    }
2673
2674    #[test]
2675    fn navigate_segments_json_string_auto_parse() {
2676        // Exec output is stored as Value::String containing JSON
2677        let value = json!(r#"{"name":"Nika","version":"0.30"}"#);
2678        let segments = vec![PathSegment::Field(Arc::from("name"))];
2679        let result = navigate_segments(&value, &segments).unwrap();
2680        assert_eq!(result, Some(json!("Nika")));
2681    }
2682
2683    #[test]
2684    fn navigate_segments_json_string_deep_access() {
2685        let value = json!(r#"{"a":{"b":{"c":"deep"}}}"#);
2686        let segments = vec![
2687            PathSegment::Field(Arc::from("a")),
2688            PathSegment::Field(Arc::from("b")),
2689            PathSegment::Field(Arc::from("c")),
2690        ];
2691        let result = navigate_segments(&value, &segments).unwrap();
2692        assert_eq!(result, Some(json!("deep")));
2693    }
2694
2695    #[test]
2696    fn navigate_segments_plain_string_returns_none() {
2697        // Non-JSON string should NOT be parsed
2698        let value = json!("hello world");
2699        let segments = vec![PathSegment::Field(Arc::from("name"))];
2700        let result = navigate_segments(&value, &segments).unwrap();
2701        assert_eq!(result, None);
2702    }
2703
2704    // ═══════════════════════════════════════════════════════════════
2705    // validate_binding_type tests
2706    // ═══════════════════════════════════════════════════════════════
2707
2708    #[test]
2709    fn validate_type_any_accepts_all() {
2710        validate_binding_type(&json!("str"), BindingType::Any, "a", "p").unwrap();
2711        validate_binding_type(&json!(42), BindingType::Any, "a", "p").unwrap();
2712        validate_binding_type(&json!(true), BindingType::Any, "a", "p").unwrap();
2713        validate_binding_type(&json!([]), BindingType::Any, "a", "p").unwrap();
2714        validate_binding_type(&json!({}), BindingType::Any, "a", "p").unwrap();
2715        validate_binding_type(&json!(null), BindingType::Any, "a", "p").unwrap();
2716    }
2717
2718    #[test]
2719    fn validate_type_string_rejects_number() {
2720        let result = validate_binding_type(&json!(42), BindingType::String, "a", "p");
2721        assert!(result.is_err());
2722    }
2723
2724    #[test]
2725    fn validate_type_number_accepts_int_and_float() {
2726        validate_binding_type(&json!(42), BindingType::Number, "a", "p").unwrap();
2727        validate_binding_type(&json!(3.12), BindingType::Number, "a", "p").unwrap();
2728    }
2729
2730    #[test]
2731    fn validate_type_integer_rejects_float() {
2732        let result = validate_binding_type(&json!(3.12), BindingType::Integer, "a", "p");
2733        assert!(result.is_err());
2734    }
2735
2736    #[test]
2737    fn validate_type_boolean_rejects_string() {
2738        let result = validate_binding_type(&json!("true"), BindingType::Boolean, "a", "p");
2739        assert!(result.is_err());
2740    }
2741
2742    // ═══════════════════════════════════════════════════════════════
2743    // json_type_name tests
2744    // ═══════════════════════════════════════════════════════════════
2745
2746    #[test]
2747    fn json_type_names() {
2748        assert_eq!(json_type_name(&json!(null)), "null");
2749        assert_eq!(json_type_name(&json!(true)), "boolean");
2750        assert_eq!(json_type_name(&json!(42)), "number");
2751        assert_eq!(json_type_name(&json!("str")), "string");
2752        assert_eq!(json_type_name(&json!([])), "array");
2753        assert_eq!(json_type_name(&json!({})), "object");
2754    }
2755
2756    // ========== source_task_id tracking ==========
2757
2758    #[test]
2759    fn source_task_id_set_with_source() {
2760        let mut bindings = ResolvedBindings::new();
2761        bindings.set_with_source("img", json!("output text"), "gen_img");
2762
2763        assert_eq!(bindings.source_task_id("img"), Some("gen_img"));
2764        assert_eq!(bindings.source_task_id("nonexistent"), None);
2765
2766        // The value should still be accessible
2767        assert_eq!(bindings.get("img"), Some(&json!("output text")));
2768    }
2769
2770    #[test]
2771    fn source_task_id_plain_set_has_no_tracking() {
2772        let mut bindings = ResolvedBindings::new();
2773        bindings.set("img", json!("output text"));
2774
2775        // Plain set() does not track source task ID
2776        assert_eq!(bindings.source_task_id("img"), None);
2777    }
2778
2779    #[test]
2780    fn source_task_id_from_binding_spec() {
2781        use crate::binding::BindingEntry;
2782
2783        let mut spec = BindingSpec::default();
2784        spec.insert("forecast".to_string(), BindingEntry::new("weather.summary"));
2785
2786        let store = RunContext::new();
2787        store.insert(
2788            std::sync::Arc::from("weather"),
2789            crate::store::TaskResult::success(
2790                json!({"summary": "Sunny"}),
2791                std::time::Duration::from_secs(1),
2792            ),
2793        );
2794
2795        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
2796
2797        // "forecast" binding came from $weather -> source task ID is "weather"
2798        assert_eq!(bindings.source_task_id("forecast"), Some("weather"));
2799    }
2800
2801    // =========================================================================
2802    // Media Tool Results → Binding Spec → Template Resolution
2803    // =========================================================================
2804    //
2805    // These tests verify the end-to-end path:
2806    //   invoke result → RunContext → BindingSpec → ResolvedBindings → get_resolved
2807    //
2808    // Three scenarios:
2809    //   1. Invoke output (JSON) fields accessible via binding paths
2810    //   2. Media refs (side-channel) accessible via media[N].field paths
2811    //   3. Chained bindings: gen.media[0].hash + thumb.hash both accessible
2812
2813    /// Helper: populate a RunContext with a "gen" task that has media refs
2814    /// and a "thumb" task that has thumbnail JSON output.
2815    fn store_with_media_chain() -> RunContext {
2816        let store = RunContext::new();
2817
2818        // Task "gen": produces an image with media refs in the side-channel
2819        let gen_media = vec![crate::media::MediaRef {
2820            hash: "blake3:abc123def456".to_string(),
2821            mime_type: "image/png".to_string(),
2822            size_bytes: 1048576,
2823            path: std::path::PathBuf::from("/tmp/cas/ab/c123def456"),
2824            extension: "png".to_string(),
2825            created_by: "gen".to_string(),
2826            metadata: {
2827                let mut m = serde_json::Map::new();
2828                m.insert("width".to_string(), json!(1024));
2829                m.insert("height".to_string(), json!(768));
2830                m
2831            },
2832        }];
2833        store.insert(
2834            Arc::from("gen"),
2835            TaskResult::success(json!({"prompt": "a sunset photo"}), Duration::from_secs(3))
2836                .with_media(gen_media),
2837        );
2838
2839        // Task "thumb": invoke result stored as JSON-string output
2840        store.insert(
2841            Arc::from("thumb"),
2842            TaskResult::success_str(
2843                r#"{"hash":"blake3:thumb_999","mime_type":"image/png","size_bytes":2048,"metadata":{"width":256,"height":192}}"#,
2844                Duration::from_millis(100),
2845            ),
2846        );
2847
2848        store
2849    }
2850
2851    // ----- Scenario 1: Invoke output fields via binding spec -----
2852
2853    #[test]
2854    fn binding_spec_resolves_invoke_output_hash() {
2855        use crate::binding::BindingEntry;
2856
2857        let store = store_with_media_chain();
2858        let mut spec = BindingSpec::default();
2859        // with: { thumb_hash: $thumb.hash }
2860        spec.insert("thumb_hash".to_string(), BindingEntry::new("thumb.hash"));
2861
2862        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
2863        let value = bindings.get_resolved("thumb_hash", &store).unwrap();
2864        assert_eq!(value, json!("blake3:thumb_999"));
2865    }
2866
2867    #[test]
2868    fn binding_spec_resolves_invoke_output_nested_metadata() {
2869        use crate::binding::BindingEntry;
2870
2871        let store = store_with_media_chain();
2872        let mut spec = BindingSpec::default();
2873        // with: { thumb_width: $thumb.metadata.width }
2874        spec.insert(
2875            "thumb_width".to_string(),
2876            BindingEntry::new("thumb.metadata.width"),
2877        );
2878
2879        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
2880        let value = bindings.get_resolved("thumb_width", &store).unwrap();
2881        assert_eq!(value, json!(256));
2882    }
2883
2884    #[test]
2885    fn binding_spec_resolves_invoke_output_mime_type() {
2886        use crate::binding::BindingEntry;
2887
2888        let store = store_with_media_chain();
2889        let mut spec = BindingSpec::default();
2890        spec.insert(
2891            "thumb_mime".to_string(),
2892            BindingEntry::new("thumb.mime_type"),
2893        );
2894
2895        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
2896        let value = bindings.get_resolved("thumb_mime", &store).unwrap();
2897        assert_eq!(value, json!("image/png"));
2898    }
2899
2900    // ----- Scenario 2: Media refs via binding spec -----
2901
2902    #[test]
2903    fn binding_spec_resolves_media_ref_hash() {
2904        use crate::binding::BindingEntry;
2905
2906        let store = store_with_media_chain();
2907        let mut spec = BindingSpec::default();
2908        // with: { gen_hash: $gen.media[0].hash }
2909        spec.insert(
2910            "gen_hash".to_string(),
2911            BindingEntry::new("gen.media[0].hash"),
2912        );
2913
2914        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
2915        let value = bindings.get_resolved("gen_hash", &store).unwrap();
2916        assert_eq!(value, json!("blake3:abc123def456"));
2917    }
2918
2919    #[test]
2920    fn binding_spec_resolves_media_ref_enriched_width() {
2921        use crate::binding::BindingEntry;
2922
2923        let store = store_with_media_chain();
2924        let mut spec = BindingSpec::default();
2925        // with: { gen_width: $gen.media[0].metadata.width }
2926        spec.insert(
2927            "gen_width".to_string(),
2928            BindingEntry::new("gen.media[0].metadata.width"),
2929        );
2930
2931        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
2932        let value = bindings.get_resolved("gen_width", &store).unwrap();
2933        assert_eq!(value, json!(1024));
2934    }
2935
2936    #[test]
2937    fn binding_spec_resolves_media_ref_mime_type() {
2938        use crate::binding::BindingEntry;
2939
2940        let store = store_with_media_chain();
2941        let mut spec = BindingSpec::default();
2942        spec.insert(
2943            "gen_mime".to_string(),
2944            BindingEntry::new("gen.media[0].mime_type"),
2945        );
2946
2947        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
2948        let value = bindings.get_resolved("gen_mime", &store).unwrap();
2949        assert_eq!(value, json!("image/png"));
2950    }
2951
2952    #[test]
2953    fn binding_spec_resolves_media_full_array() {
2954        use crate::binding::BindingEntry;
2955
2956        let store = store_with_media_chain();
2957        let mut spec = BindingSpec::default();
2958        // with: { all_media: $gen.media }
2959        spec.insert("all_media".to_string(), BindingEntry::new("gen.media"));
2960
2961        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
2962        let value = bindings.get_resolved("all_media", &store).unwrap();
2963        let arr = value.as_array().expect("media should be an array");
2964        assert_eq!(arr.len(), 1);
2965        assert_eq!(arr[0]["hash"], "blake3:abc123def456");
2966    }
2967
2968    // ----- Scenario 3: Chained bindings -----
2969
2970    #[test]
2971    fn binding_spec_chained_gen_media_and_thumb_output() {
2972        use crate::binding::BindingEntry;
2973
2974        let store = store_with_media_chain();
2975        let mut spec = BindingSpec::default();
2976
2977        // Bind both gen.media[0].hash and thumb.hash in one spec
2978        spec.insert(
2979            "source_hash".to_string(),
2980            BindingEntry::new("gen.media[0].hash"),
2981        );
2982        spec.insert("thumb_hash".to_string(), BindingEntry::new("thumb.hash"));
2983        spec.insert(
2984            "thumb_width".to_string(),
2985            BindingEntry::new("thumb.metadata.width"),
2986        );
2987
2988        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
2989
2990        // Verify all three bindings resolve correctly
2991        assert_eq!(
2992            bindings.get_resolved("source_hash", &store).unwrap(),
2993            json!("blake3:abc123def456"),
2994            "gen.media[0].hash should resolve via media side-channel"
2995        );
2996        assert_eq!(
2997            bindings.get_resolved("thumb_hash", &store).unwrap(),
2998            json!("blake3:thumb_999"),
2999            "thumb.hash should resolve via JSON-string auto-parse"
3000        );
3001        assert_eq!(
3002            bindings.get_resolved("thumb_width", &store).unwrap(),
3003            json!(256),
3004            "thumb.metadata.width should resolve via nested JSON-string traversal"
3005        );
3006    }
3007
3008    #[test]
3009    fn binding_spec_lazy_media_ref_resolves_on_access() {
3010        use crate::binding::BindingEntry;
3011
3012        let store = store_with_media_chain();
3013        let mut spec = BindingSpec::default();
3014        // Lazy binding: resolution deferred until get_resolved
3015        spec.insert(
3016            "lazy_hash".to_string(),
3017            BindingEntry {
3018                path: "gen.media[0].hash".to_string(),
3019                default: None,
3020                lazy: true,
3021            },
3022        );
3023
3024        let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
3025
3026        // Should be pending initially
3027        assert!(bindings.is_lazy("lazy_hash"));
3028
3029        // But still resolves correctly via get_resolved
3030        let value = bindings.get_resolved("lazy_hash", &store).unwrap();
3031        assert_eq!(value, json!("blake3:abc123def456"));
3032    }
3033}