Skip to main content

nika_core/binding/
entry.rs

1//! Binding Spec — YAML types for explicit data binding
2//!
3//! Unified syntax: `alias: task.path [?? default]`
4//!
5//! Examples:
6//! - `forecast: weather.summary` -> simple path (eager)
7//! - `temp: weather.data.temp ?? 20` -> with numeric default
8//! - `name: user.profile ?? "Anonymous"` -> with string default (quoted)
9//! - `cfg: x ?? {"a": 1}` -> with object default
10//!
11//! Extended syntax for lazy bindings:
12//! - `alias: { path: task.result, lazy: true }` -> deferred resolution
13//! - `alias: { path: task.result, lazy: true, default: "fallback" }` -> lazy with default
14
15use rustc_hash::FxHashMap;
16use serde::de::{self, Deserializer, MapAccess, Visitor};
17use serde::Deserialize;
18use serde_json::Value;
19use std::fmt;
20
21use crate::error::CoreError;
22
23use super::transform::TransformExpr;
24use super::types::{BindingPath, BindingType};
25
26/// Binding spec - map of alias to entry (YAML `with:` block)
27pub type BindingSpec = FxHashMap<String, BindingEntry>;
28
29/// Unified with entry - supports both string and extended object syntax
30///
31/// String syntax: `task.path [?? default]`
32/// - path: "task.field.subfield" or "task" for entire output
33/// - default: Optional JSON literal after ??
34///
35/// Extended syntax (YAML object):
36/// - path: "task.field" (required)
37/// - lazy: bool (optional, default false)
38/// - default: JSON value (optional)
39#[derive(Debug, Clone, PartialEq)]
40pub struct BindingEntry {
41    /// Full path: "task.field.subfield" or "task" for entire output
42    pub path: String,
43    /// Optional default value (JSON literal)
44    pub default: Option<Value>,
45    /// Lazy flag - if true, resolution is deferred until first access
46    pub lazy: bool,
47}
48
49impl BindingEntry {
50    /// Create a new BindingEntry with just a path (eager resolution)
51    pub fn new(path: impl Into<String>) -> Self {
52        Self {
53            path: path.into(),
54            default: None,
55            lazy: false,
56        }
57    }
58
59    /// Create a new BindingEntry with path and default (eager resolution)
60    pub fn with_default(path: impl Into<String>, default: Value) -> Self {
61        Self {
62            path: path.into(),
63            default: Some(default),
64            lazy: false,
65        }
66    }
67
68    /// Create a new lazy BindingEntry (deferred resolution)
69    pub fn new_lazy(path: impl Into<String>) -> Self {
70        Self {
71            path: path.into(),
72            default: None,
73            lazy: true,
74        }
75    }
76
77    /// Create a new lazy BindingEntry with default (deferred resolution)
78    pub fn lazy_with_default(path: impl Into<String>, default: Value) -> Self {
79        Self {
80            path: path.into(),
81            default: Some(default),
82            lazy: true,
83        }
84    }
85
86    /// Check if this binding is lazy (deferred resolution)
87    pub fn is_lazy(&self) -> bool {
88        self.lazy
89    }
90
91    /// Extract the task ID from the path (first segment before '.')
92    pub fn task_id(&self) -> &str {
93        self.path.split('.').next().unwrap_or(&self.path)
94    }
95
96    /// Normalize a binding path by stripping the `$` prefix if present.
97    ///
98    /// This enables implicit output reference syntax where `$task` is
99    /// syntactic sugar for `task`. The RunContext.resolve_path() function
100    /// already handles resolving bare task IDs to their full output.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use nika_core::binding::BindingEntry;
106    ///
107    /// assert_eq!(BindingEntry::normalize_path("$task1"), "task1");
108    /// assert_eq!(BindingEntry::normalize_path("task1"), "task1");
109    /// assert_eq!(BindingEntry::normalize_path("$my_task"), "my_task");
110    /// assert_eq!(BindingEntry::normalize_path("task.field"), "task.field");
111    /// assert_eq!(BindingEntry::normalize_path("$task.field"), "task.field");
112    /// ```
113    #[inline]
114    pub fn normalize_path(path: &str) -> &str {
115        path.strip_prefix('$').unwrap_or(path)
116    }
117}
118
119/// Parse a binding entry string into BindingEntry (eager resolution)
120///
121/// Syntax: `task.path [?? default]`
122/// - If `??` found outside quotes, splits into path and default
123/// - Default is parsed as JSON literal (strings must be quoted)
124/// - String syntax always produces eager bindings (lazy=false)
125pub fn parse_binding_entry(s: &str) -> Result<BindingEntry, CoreError> {
126    let s = s.trim();
127
128    if s.is_empty() {
129        return Err(CoreError::InvalidPath {
130            path: String::new(),
131        });
132    }
133
134    match find_operator_outside_quotes(s, "??") {
135        Some(idx) => {
136            let path = s[..idx].trim();
137
138            if path.is_empty() {
139                return Err(CoreError::InvalidPath {
140                    path: s.to_string(),
141                });
142            }
143
144            let default_str = s[idx + 2..].trim();
145            let default =
146                serde_json::from_str(default_str).map_err(|e| CoreError::InvalidDefault {
147                    raw: default_str.to_string(),
148                    reason: e.to_string(),
149                })?;
150
151            Ok(BindingEntry {
152                path: path.to_string(),
153                default: Some(default),
154                lazy: false,
155            })
156        }
157        None => Ok(BindingEntry {
158            path: s.to_string(),
159            default: None,
160            lazy: false,
161        }),
162    }
163}
164
165/// Find the position of an operator outside of quoted strings
166///
167/// Handles double-quoted strings ("...") and ignores operator inside quotes.
168/// Example: `x ?? "What?? Really??"` -> finds first ?? at position 2
169fn find_operator_outside_quotes(s: &str, op: &str) -> Option<usize> {
170    let mut in_quotes = false;
171    let mut escape_next = false;
172    let mut byte_pos = 0;
173
174    for ch in s.chars() {
175        if escape_next {
176            escape_next = false;
177        } else if ch == '\\' {
178            escape_next = true;
179        } else if ch == '"' {
180            in_quotes = !in_quotes;
181        } else if !in_quotes && s[byte_pos..].starts_with(op) {
182            return Some(byte_pos);
183        }
184
185        byte_pos += ch.len_utf8();
186    }
187
188    None
189}
190
191/// Custom deserializer for BindingEntry
192///
193/// Accepts two formats:
194/// 1. String: `task.path [?? default]` → eager binding
195/// 2. Object: `{path: "task.path", lazy: true, default: ...}` → lazy binding
196impl<'de> Deserialize<'de> for BindingEntry {
197    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
198    where
199        D: Deserializer<'de>,
200    {
201        deserializer.deserialize_any(BindingEntryVisitor)
202    }
203}
204
205struct BindingEntryVisitor;
206
207impl<'de> Visitor<'de> for BindingEntryVisitor {
208    type Value = BindingEntry;
209
210    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
211        formatter
212            .write_str("a string 'task.path [?? default]' or an object {path, lazy?, default?}")
213    }
214
215    /// Handle string format: "task.path [?? default]" (eager)
216    /// Applies normalize_path() to strip $ prefix from implicit output syntax ($task → task)
217    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
218    where
219        E: de::Error,
220    {
221        let mut entry = parse_binding_entry(value).map_err(|e| de::Error::custom(e.to_string()))?;
222        entry.path = BindingEntry::normalize_path(&entry.path).to_string();
223        Ok(entry)
224    }
225
226    /// Handle object format: {path, lazy?, default?}
227    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
228    where
229        M: MapAccess<'de>,
230    {
231        let mut path: Option<String> = None;
232        let mut lazy: Option<bool> = None;
233        let mut default: Option<Value> = None;
234
235        while let Some(key) = map.next_key::<String>()? {
236            match key.as_str() {
237                "path" => {
238                    if path.is_some() {
239                        return Err(de::Error::duplicate_field("path"));
240                    }
241                    path = Some(map.next_value()?);
242                }
243                "lazy" => {
244                    if lazy.is_some() {
245                        return Err(de::Error::duplicate_field("lazy"));
246                    }
247                    lazy = Some(map.next_value()?);
248                }
249                "default" => {
250                    if default.is_some() {
251                        return Err(de::Error::duplicate_field("default"));
252                    }
253                    default = Some(map.next_value()?);
254                }
255                _ => {
256                    // Ignore unknown fields for forward compatibility
257                    let _ = map.next_value::<de::IgnoredAny>()?;
258                }
259            }
260        }
261
262        let path = path.ok_or_else(|| de::Error::missing_field("path"))?;
263        let path = BindingEntry::normalize_path(&path).to_string();
264
265        Ok(BindingEntry {
266            path,
267            default,
268            lazy: lazy.unwrap_or(false),
269        })
270    }
271}
272
273// ═══════════════════════════════════════════════════════════════
274// WithEntry -- new binding system
275// ═══════════════════════════════════════════════════════════════
276
277/// A single binding entry in the `with:` block
278///
279/// Supports two YAML forms:
280///
281/// **String form** (most common):
282/// ```yaml
283/// with:
284///   result: $step1
285///   title: $step1.title | upper
286///   count: $step1.items | length ?? 0
287/// ```
288///
289/// **Object form** (for complex cases):
290/// ```yaml
291/// with:
292///   summary:
293///     from: $step1.abstract
294///     type: string
295///     transform: lower | trim
296///     default: "No abstract"
297///     lazy: true
298/// ```
299#[derive(Debug, Clone)]
300pub struct WithEntry {
301    /// Parsed source path (e.g., $step1.data.items)
302    pub source: BindingPath,
303    /// Type constraint (default: Any)
304    pub binding_type: BindingType,
305    /// Default value if source is null/missing (applied AFTER transforms)
306    pub default: Option<Value>,
307    /// Defer resolution until first access
308    pub lazy: bool,
309    /// Transform pipeline to apply after resolution
310    pub transform: Option<TransformExpr>,
311}
312
313/// Map of alias -> WithEntry (YAML `with:` block)
314pub type WithSpec = FxHashMap<String, WithEntry>;
315
316/// Error parsing a WithEntry string (NIKA-155)
317#[derive(Debug, Clone, PartialEq)]
318pub struct WithEntryParseError {
319    pub input: String,
320    pub reason: String,
321}
322
323impl fmt::Display for WithEntryParseError {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        write!(
326            f,
327            "[NIKA-155] WithEntry parse error in '{}': {}",
328            self.input, self.reason
329        )
330    }
331}
332
333impl std::error::Error for WithEntryParseError {}
334
335impl WithEntry {
336    /// Create a simple WithEntry from a BindingPath (no transforms, no default)
337    pub fn simple(source: BindingPath) -> Self {
338        Self {
339            source,
340            binding_type: BindingType::default(),
341            default: None,
342            lazy: false,
343            transform: None,
344        }
345    }
346
347    /// Create a WithEntry with a default value
348    pub fn with_default(source: BindingPath, default: Value) -> Self {
349        Self {
350            source,
351            binding_type: BindingType::default(),
352            default: Some(default),
353            lazy: false,
354            transform: None,
355        }
356    }
357
358    /// Extract the task ID if this binding references a task output.
359    ///
360    /// Returns `Some(task_id)` for Task sources, `None` for Context/Input/Env/LoopVar.
361    pub fn task_id(&self) -> Option<&str> {
362        use super::types::BindingSource;
363        match &self.source.source {
364            BindingSource::Task(id) => Some(id),
365            _ => None,
366        }
367    }
368
369    /// Check if this binding is lazy (deferred resolution)
370    pub fn is_lazy(&self) -> bool {
371        self.lazy
372    }
373}
374
375/// Parse a with-entry from its string form
376///
377/// Grammar:
378/// ```text
379///   entry     := path ("|" transform)* ("??" default)?
380///   path      := "$" identifier ("." identifier | "[" index "]")*
381///   transform := name | name "(" args ")"
382///   default   := json_value
383/// ```
384///
385/// Examples:
386/// ```text
387///   "$step1"                          → simple task ref
388///   "$step1.data"                     → task ref with field access
389///   "$step1.data ?? fallback"         → with JSON default
390///   "$step1.data | upper"             → with transform
391///   "$step1.data | sort | unique"     → transform chain
392///   "$step1.data | sort | first(3) ?? []"  → chain + default
393/// ```
394pub fn parse_with_entry(input: &str) -> Result<WithEntry, WithEntryParseError> {
395    let input_trimmed = input.trim();
396
397    if input_trimmed.is_empty() {
398        return Err(WithEntryParseError {
399            input: input.to_string(),
400            reason: "empty input".to_string(),
401        });
402    }
403
404    // Step 1: Split off the default (" ?? ") -- must be outside quotes
405    let (path_and_transforms, default_value) = split_default(input_trimmed)?;
406
407    if path_and_transforms.is_empty() {
408        return Err(WithEntryParseError {
409            input: input.to_string(),
410            reason: "empty path before '??'".to_string(),
411        });
412    }
413
414    // Step 2: Split path from transforms by "|"
415    let (path_str, transform_str) = split_transforms(path_and_transforms);
416
417    let path_str = path_str.trim();
418    if path_str.is_empty() {
419        return Err(WithEntryParseError {
420            input: input.to_string(),
421            reason: "empty path".to_string(),
422        });
423    }
424
425    // Step 3: Parse the path as a BindingPath
426    let source = BindingPath::parse(path_str).map_err(|e| WithEntryParseError {
427        input: input.to_string(),
428        reason: e.reason,
429    })?;
430
431    // Step 4: Parse transforms (if any)
432    let transform = if let Some(t_str) = transform_str {
433        let t_str = t_str.trim();
434        if t_str.is_empty() {
435            return Err(WithEntryParseError {
436                input: input.to_string(),
437                reason: "empty transform after '|'".to_string(),
438            });
439        }
440        Some(
441            TransformExpr::parse(t_str).map_err(|e| WithEntryParseError {
442                input: input.to_string(),
443                reason: e.reason,
444            })?,
445        )
446    } else {
447        None
448    };
449
450    // Step 5: Parse default value (if any) -- must be valid JSON
451    let default = match default_value {
452        Some(d_str) => {
453            let d_str = d_str.trim();
454            if d_str.is_empty() {
455                return Err(WithEntryParseError {
456                    input: input.to_string(),
457                    reason: "empty default value after '??'".to_string(),
458                });
459            }
460            let val: Value = serde_json::from_str(d_str).map_err(|e| WithEntryParseError {
461                input: input.to_string(),
462                reason: format!("invalid default JSON: {e}"),
463            })?;
464            Some(val)
465        }
466        None => None,
467    };
468
469    Ok(WithEntry {
470        source,
471        binding_type: BindingType::default(),
472        default,
473        lazy: false,
474        transform,
475    })
476}
477
478/// Split the input at " ?? " to separate the path+transforms from the default value.
479///
480/// Respects double-quoted strings: `$x | default("a ?? b") ?? "fallback"`
481/// would split at the final `??`, not the one inside `default()`.
482fn split_default(s: &str) -> Result<(&str, Option<&str>), WithEntryParseError> {
483    // Find the LAST " ?? " outside of quotes and parens
484    // We scan left-to-right tracking quote/paren state, recording each valid `??` position.
485    // The rightmost `??` outside quotes becomes the split point.
486    let mut in_quotes = false;
487    let mut escape_next = false;
488    let mut paren_depth: u32 = 0;
489    let mut last_default_pos: Option<usize> = None;
490    let bytes = s.as_bytes();
491
492    let mut i = 0;
493    while i < bytes.len() {
494        if escape_next {
495            escape_next = false;
496            i += 1;
497            continue;
498        }
499
500        match bytes[i] {
501            b'\\' => {
502                escape_next = true;
503            }
504            b'"' => {
505                in_quotes = !in_quotes;
506            }
507            b'(' if !in_quotes => {
508                paren_depth = paren_depth.saturating_add(1);
509            }
510            b')' if !in_quotes => {
511                paren_depth = paren_depth.saturating_sub(1);
512            }
513            b'?' if !in_quotes && paren_depth == 0 => {
514                // Check for `??`
515                if i + 1 < bytes.len() && bytes[i + 1] == b'?' {
516                    last_default_pos = Some(i);
517                    i += 2; // skip both `?`
518                    continue;
519                }
520            }
521            _ => {}
522        }
523        i += 1;
524    }
525
526    match last_default_pos {
527        Some(pos) => {
528            let path_part = &s[..pos].trim_end();
529            let default_part = &s[pos + 2..].trim_start();
530            Ok((path_part, Some(default_part)))
531        }
532        None => Ok((s, None)),
533    }
534}
535
536/// Split the path+transforms at the FIRST `|` outside quotes and parens.
537///
538/// Returns `(path, Some(transforms))` or `(path, None)`.
539fn split_transforms(s: &str) -> (&str, Option<&str>) {
540    let mut in_quotes = false;
541    let mut escape_next = false;
542    let mut paren_depth: u32 = 0;
543    let bytes = s.as_bytes();
544
545    for (i, &b) in bytes.iter().enumerate() {
546        if escape_next {
547            escape_next = false;
548            continue;
549        }
550
551        match b {
552            b'\\' => escape_next = true,
553            b'"' => in_quotes = !in_quotes,
554            b'(' if !in_quotes => paren_depth = paren_depth.saturating_add(1),
555            b')' if !in_quotes => paren_depth = paren_depth.saturating_sub(1),
556            b'|' if !in_quotes && paren_depth == 0 => {
557                return (&s[..i], Some(&s[i + 1..]));
558            }
559            _ => {}
560        }
561    }
562
563    (s, None)
564}
565
566/// Custom deserializer for WithEntry
567///
568/// Accepts two YAML formats:
569/// 1. String: `"$step1.data | upper ?? fallback"` → parsed by `parse_with_entry`
570/// 2. Object: `{ from: "$step1", type: string, transform: "upper", default: "x", lazy: true }`
571impl<'de> Deserialize<'de> for WithEntry {
572    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
573    where
574        D: Deserializer<'de>,
575    {
576        deserializer.deserialize_any(WithEntryVisitor)
577    }
578}
579
580struct WithEntryVisitor;
581
582impl<'de> Visitor<'de> for WithEntryVisitor {
583    type Value = WithEntry;
584
585    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
586        formatter.write_str(
587            "a string '$path | transform ?? default' or an object \
588             { from, type?, transform?, default?, lazy? }",
589        )
590    }
591
592    /// Handle string form: "$step1.data | upper ?? fallback"
593    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
594    where
595        E: de::Error,
596    {
597        parse_with_entry(value).map_err(|e| de::Error::custom(e.to_string()))
598    }
599
600    /// Handle object form: { from, type?, transform?, default?, lazy? }
601    fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
602    where
603        M: MapAccess<'de>,
604    {
605        // Use a helper struct for the object form
606        #[derive(Deserialize)]
607        struct WithEntryObject {
608            from: String,
609            #[serde(rename = "type", default)]
610            binding_type: BindingType,
611            #[serde(default)]
612            transform: Option<String>,
613            #[serde(default)]
614            default: Option<Value>,
615            #[serde(default)]
616            lazy: bool,
617        }
618
619        let obj = WithEntryObject::deserialize(de::value::MapAccessDeserializer::new(map))?;
620
621        // Parse `from:` as BindingPath
622        let source = BindingPath::parse(&obj.from)
623            .map_err(|e| de::Error::custom(format!("[NIKA-155] invalid 'from' path: {e}")))?;
624
625        // Parse `transform:` as TransformExpr (if present)
626        let transform = match obj.transform {
627            Some(ref t_str) if !t_str.trim().is_empty() => Some(
628                TransformExpr::parse(t_str.trim())
629                    .map_err(|e| de::Error::custom(format!("[NIKA-155] invalid transform: {e}")))?,
630            ),
631            _ => None,
632        };
633
634        Ok(WithEntry {
635            source,
636            binding_type: obj.binding_type,
637            default: obj.default,
638            lazy: obj.lazy,
639            transform,
640        })
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::super::transform::TransformOp;
647    use super::super::types::BindingSource;
648    use super::*;
649    use serde_json::json;
650    use serde_saphyr as serde_yaml;
651
652    // ═══════════════════════════════════════════════════════════════
653    // parse_binding_entry() tests - TDD: write these first
654    // ═══════════════════════════════════════════════════════════════
655
656    #[test]
657    fn parse_simple_path() {
658        let entry = parse_binding_entry("weather.summary").unwrap();
659        assert_eq!(entry.path, "weather.summary");
660        assert_eq!(entry.default, None);
661    }
662
663    #[test]
664    fn parse_simple_task_only() {
665        let entry = parse_binding_entry("weather").unwrap();
666        assert_eq!(entry.path, "weather");
667        assert_eq!(entry.default, None);
668    }
669
670    #[test]
671    fn parse_nested_path() {
672        let entry = parse_binding_entry("weather.data.temperature.celsius").unwrap();
673        assert_eq!(entry.path, "weather.data.temperature.celsius");
674        assert_eq!(entry.default, None);
675    }
676
677    #[test]
678    fn parse_with_default_number() {
679        let entry = parse_binding_entry("x.y ?? 0").unwrap();
680        assert_eq!(entry.path, "x.y");
681        assert_eq!(entry.default, Some(json!(0)));
682    }
683
684    #[test]
685    fn parse_with_default_negative_number() {
686        let entry = parse_binding_entry("score ?? -1").unwrap();
687        assert_eq!(entry.path, "score");
688        assert_eq!(entry.default, Some(json!(-1)));
689    }
690
691    #[test]
692    fn parse_with_default_float() {
693        let entry = parse_binding_entry("rate ?? 0.5").unwrap();
694        assert_eq!(entry.path, "rate");
695        assert_eq!(entry.default, Some(json!(0.5)));
696    }
697
698    #[test]
699    fn parse_with_default_string() {
700        let entry = parse_binding_entry(r#"x.y ?? "Anon""#).unwrap();
701        assert_eq!(entry.path, "x.y");
702        assert_eq!(entry.default, Some(json!("Anon")));
703    }
704
705    #[test]
706    fn parse_with_default_empty_string() {
707        let entry = parse_binding_entry(r#"name ?? """#).unwrap();
708        assert_eq!(entry.path, "name");
709        assert_eq!(entry.default, Some(json!("")));
710    }
711
712    #[test]
713    fn parse_with_default_bool_true() {
714        let entry = parse_binding_entry("enabled ?? true").unwrap();
715        assert_eq!(entry.path, "enabled");
716        assert_eq!(entry.default, Some(json!(true)));
717    }
718
719    #[test]
720    fn parse_with_default_bool_false() {
721        let entry = parse_binding_entry("enabled ?? false").unwrap();
722        assert_eq!(entry.path, "enabled");
723        assert_eq!(entry.default, Some(json!(false)));
724    }
725
726    #[test]
727    fn parse_with_default_null() {
728        let entry = parse_binding_entry("value ?? null").unwrap();
729        assert_eq!(entry.path, "value");
730        assert_eq!(entry.default, Some(json!(null)));
731    }
732
733    #[test]
734    fn parse_with_default_object() {
735        let entry = parse_binding_entry(r#"x ?? {"a": 1, "b": 2}"#).unwrap();
736        assert_eq!(entry.path, "x");
737        assert_eq!(entry.default, Some(json!({"a": 1, "b": 2})));
738    }
739
740    #[test]
741    fn parse_with_default_array() {
742        let entry = parse_binding_entry(r#"tags ?? ["untagged"]"#).unwrap();
743        assert_eq!(entry.path, "tags");
744        assert_eq!(entry.default, Some(json!(["untagged"])));
745    }
746
747    #[test]
748    fn parse_with_default_nested_object() {
749        let entry = parse_binding_entry(r#"cfg ?? {"debug": false, "nested": {"a": 1}}"#).unwrap();
750        assert_eq!(entry.path, "cfg");
751        assert_eq!(
752            entry.default,
753            Some(json!({"debug": false, "nested": {"a": 1}}))
754        );
755    }
756
757    #[test]
758    fn parse_quotes_in_default() {
759        // The ?? inside quotes should be ignored
760        let entry = parse_binding_entry(r#"x ?? "What?? Really??""#).unwrap();
761        assert_eq!(entry.path, "x");
762        assert_eq!(entry.default, Some(json!("What?? Really??")));
763    }
764
765    #[test]
766    fn parse_escaped_quotes_in_default() {
767        let entry = parse_binding_entry(r#"x ?? "He said \"hello\"""#).unwrap();
768        assert_eq!(entry.path, "x");
769        assert_eq!(entry.default, Some(json!("He said \"hello\"")));
770    }
771
772    #[test]
773    fn parse_with_whitespace() {
774        let entry = parse_binding_entry("  weather.summary  ").unwrap();
775        assert_eq!(entry.path, "weather.summary");
776    }
777
778    #[test]
779    fn parse_with_whitespace_around_operator() {
780        let entry = parse_binding_entry("x  ??  0").unwrap();
781        assert_eq!(entry.path, "x");
782        assert_eq!(entry.default, Some(json!(0)));
783    }
784
785    // ═══════════════════════════════════════════════════════════════
786    // Error cases - TDD: these should fail appropriately
787    // ═══════════════════════════════════════════════════════════════
788
789    #[test]
790    fn parse_reject_unquoted_string() {
791        // "Anonymous" without quotes is invalid JSON
792        let result = parse_binding_entry("x ?? Anonymous");
793        assert!(result.is_err());
794        let err = result.unwrap_err();
795        assert!(err.to_string().contains("NIKA-056"));
796    }
797
798    #[test]
799    fn parse_reject_empty_path() {
800        let result = parse_binding_entry("");
801        assert!(result.is_err());
802    }
803
804    #[test]
805    fn parse_reject_only_operator() {
806        let result = parse_binding_entry("??");
807        assert!(result.is_err());
808    }
809
810    #[test]
811    fn parse_reject_empty_path_with_default() {
812        let result = parse_binding_entry("?? 0");
813        assert!(result.is_err());
814    }
815
816    #[test]
817    fn parse_reject_invalid_json_default() {
818        // Missing closing brace
819        let result = parse_binding_entry(r#"x ?? {"a": 1"#);
820        assert!(result.is_err());
821    }
822
823    // ═══════════════════════════════════════════════════════════════
824    // task_id() extraction tests
825    // ═══════════════════════════════════════════════════════════════
826
827    #[test]
828    fn task_id_simple() {
829        let entry = BindingEntry::new("weather");
830        assert_eq!(entry.task_id(), "weather");
831    }
832
833    #[test]
834    fn task_id_with_path() {
835        let entry = BindingEntry::new("weather.summary");
836        assert_eq!(entry.task_id(), "weather");
837    }
838
839    #[test]
840    fn task_id_with_nested_path() {
841        let entry = BindingEntry::new("weather.data.temp.celsius");
842        assert_eq!(entry.task_id(), "weather");
843    }
844
845    // ═══════════════════════════════════════════════════════════════
846    // YAML deserialization tests
847    // ═══════════════════════════════════════════════════════════════
848
849    #[test]
850    fn yaml_parse_simple() {
851        let yaml = "forecast: weather.summary";
852        let spec: BindingSpec = serde_yaml::from_str(yaml).unwrap();
853        let entry = spec.get("forecast").unwrap();
854        assert_eq!(entry.path, "weather.summary");
855        assert_eq!(entry.default, None);
856    }
857
858    #[test]
859    fn yaml_parse_with_default() {
860        let yaml = r#"temp: weather.temp ?? 20"#;
861        let spec: BindingSpec = serde_yaml::from_str(yaml).unwrap();
862        let entry = spec.get("temp").unwrap();
863        assert_eq!(entry.path, "weather.temp");
864        assert_eq!(entry.default, Some(json!(20)));
865    }
866
867    #[test]
868    fn yaml_parse_multiple_entries() {
869        let yaml = r#"
870forecast: weather.summary
871temp: weather.temp ?? 20
872name: user.name ?? "Anonymous"
873"#;
874        let spec: BindingSpec = serde_yaml::from_str(yaml).unwrap();
875
876        let forecast = spec.get("forecast").unwrap();
877        assert_eq!(forecast.path, "weather.summary");
878        assert_eq!(forecast.default, None);
879
880        let temp = spec.get("temp").unwrap();
881        assert_eq!(temp.path, "weather.temp");
882        assert_eq!(temp.default, Some(json!(20)));
883
884        let name = spec.get("name").unwrap();
885        assert_eq!(name.path, "user.name");
886        assert_eq!(name.default, Some(json!("Anonymous")));
887    }
888
889    #[test]
890    fn yaml_parse_complex_defaults() {
891        // Note: Complex JSON defaults need to be quoted in YAML
892        // because {} and [] have special meaning in YAML
893        let yaml = r#"
894cfg: 'settings ?? {"debug": false}'
895tags: 'meta.tags ?? ["default"]'
896"#;
897        let spec: BindingSpec = serde_yaml::from_str(yaml).unwrap();
898
899        let cfg = spec.get("cfg").unwrap();
900        assert_eq!(cfg.default, Some(json!({"debug": false})));
901
902        let tags = spec.get("tags").unwrap();
903        assert_eq!(tags.default, Some(json!(["default"])));
904    }
905
906    // ═══════════════════════════════════════════════════════════════
907    // find_operator_outside_quotes() tests
908    // ═══════════════════════════════════════════════════════════════
909
910    #[test]
911    fn find_op_simple() {
912        assert_eq!(find_operator_outside_quotes("a ?? b", "??"), Some(2));
913    }
914
915    #[test]
916    fn find_op_no_match() {
917        assert_eq!(find_operator_outside_quotes("a.b.c", "??"), None);
918    }
919
920    #[test]
921    fn find_op_inside_quotes_ignored() {
922        // The ?? inside quotes should be ignored
923        let s = r#"x ?? "What?? Really??""#;
924        assert_eq!(find_operator_outside_quotes(s, "??"), Some(2));
925    }
926
927    #[test]
928    fn find_op_only_inside_quotes() {
929        let s = r#""a ?? b""#;
930        assert_eq!(find_operator_outside_quotes(s, "??"), None);
931    }
932
933    #[test]
934    fn find_op_multiple_operators() {
935        // Should find first one outside quotes
936        let s = "a ?? b ?? c";
937        assert_eq!(find_operator_outside_quotes(s, "??"), Some(2));
938    }
939
940    #[test]
941    fn find_op_with_escaped_quote() {
942        let s = r#"x ?? "He said \"??\"""#;
943        assert_eq!(find_operator_outside_quotes(s, "??"), Some(2));
944    }
945
946    // ═══════════════════════════════════════════════════════════════
947    // normalize_path() tests
948    // ═══════════════════════════════════════════════════════════════
949
950    #[test]
951    fn test_normalize_path_strips_dollar_prefix() {
952        assert_eq!(BindingEntry::normalize_path("$task1"), "task1");
953        assert_eq!(BindingEntry::normalize_path("task1"), "task1");
954        assert_eq!(BindingEntry::normalize_path("$my_task"), "my_task");
955        assert_eq!(BindingEntry::normalize_path("task.field"), "task.field");
956        assert_eq!(BindingEntry::normalize_path("$task.field"), "task.field");
957    }
958
959    // ═══════════════════════════════════════════════════════════════
960    // Deserialization normalization tests
961    // ═══════════════════════════════════════════════════════════════
962
963    #[test]
964    fn test_binding_entry_deserialize_normalizes_dollar_prefix_shorthand() {
965        // Shorthand: "$task1" → BindingEntry { path: "task1", ... }
966        let entry: BindingEntry = serde_yaml::from_str("\"$task1\"").unwrap();
967        assert_eq!(entry.path, "task1");
968
969        // Without prefix should also work
970        let entry: BindingEntry = serde_yaml::from_str("\"task1\"").unwrap();
971        assert_eq!(entry.path, "task1");
972    }
973
974    #[test]
975    fn test_binding_entry_deserialize_normalizes_dollar_prefix_full_form() {
976        // Full form with $ prefix in path field
977        let entry: BindingEntry = serde_yaml::from_str(
978            r#"
979            path: "$my_task"
980            default: "fallback"
981            "#,
982        )
983        .unwrap();
984        assert_eq!(entry.path, "my_task");
985        assert_eq!(
986            entry.default.as_ref().map(|v| v.as_str()),
987            Some(Some("fallback"))
988        );
989
990        // Without prefix should also work
991        let entry: BindingEntry = serde_yaml::from_str(
992            r#"
993            path: "my_task"
994            lazy: true
995            "#,
996        )
997        .unwrap();
998        assert_eq!(entry.path, "my_task");
999        assert!(entry.lazy);
1000    }
1001
1002    // ═══════════════════════════════════════════════════════════════
1003    // Comprehensive edge case tests
1004    // ═══════════════════════════════════════════════════════════════
1005
1006    #[test]
1007    fn test_normalize_path_edge_cases() {
1008        // Multiple $ prefixes - only strip first
1009        assert_eq!(BindingEntry::normalize_path("$$task"), "$task");
1010        assert_eq!(BindingEntry::normalize_path("$$$task"), "$$task");
1011
1012        // $ in middle or end - should NOT be stripped
1013        assert_eq!(BindingEntry::normalize_path("ta$sk"), "ta$sk");
1014        assert_eq!(BindingEntry::normalize_path("task$"), "task$");
1015        assert_eq!(BindingEntry::normalize_path("ta$sk$"), "ta$sk$");
1016
1017        // Nested field access with $ prefix
1018        assert_eq!(
1019            BindingEntry::normalize_path("$task.field.subfield"),
1020            "task.field.subfield"
1021        );
1022        assert_eq!(
1023            BindingEntry::normalize_path("$task.nested.deep.path"),
1024            "task.nested.deep.path"
1025        );
1026
1027        // Empty string and edge cases
1028        assert_eq!(BindingEntry::normalize_path(""), "");
1029        assert_eq!(BindingEntry::normalize_path("$"), "");
1030        assert_eq!(BindingEntry::normalize_path("$$"), "$");
1031
1032        // Just dots
1033        assert_eq!(BindingEntry::normalize_path("$."), ".");
1034        assert_eq!(BindingEntry::normalize_path("$.."), "..");
1035        assert_eq!(BindingEntry::normalize_path(".task"), ".task");
1036        assert_eq!(BindingEntry::normalize_path("$.task"), ".task");
1037
1038        // Unicode paths (should work fine)
1039        assert_eq!(BindingEntry::normalize_path("$résultat"), "résultat");
1040        assert_eq!(BindingEntry::normalize_path("$задача"), "задача");
1041
1042        // Whitespace handling (normalize_path doesn't trim)
1043        assert_eq!(BindingEntry::normalize_path("$ task"), " task");
1044        assert_eq!(BindingEntry::normalize_path("$task "), "task ");
1045    }
1046
1047    #[test]
1048    fn test_binding_entry_deserialize_nested_field_access() {
1049        // Shorthand form with nested field access
1050        let entry: BindingEntry = serde_yaml::from_str("\"$research.summary.title\"").unwrap();
1051        assert_eq!(entry.path, "research.summary.title");
1052
1053        // Full form with nested field access
1054        let entry: BindingEntry = serde_yaml::from_str(
1055            r#"
1056            path: "$agent_result.response.data.items"
1057            lazy: true
1058            "#,
1059        )
1060        .unwrap();
1061        assert_eq!(entry.path, "agent_result.response.data.items");
1062        assert!(entry.lazy);
1063    }
1064
1065    #[test]
1066    fn test_binding_entry_deserialize_multiple_dollar_signs() {
1067        // Multiple $ at start - only first stripped
1068        let entry: BindingEntry = serde_yaml::from_str("\"$$task\"").unwrap();
1069        assert_eq!(entry.path, "$task");
1070
1071        let entry: BindingEntry = serde_yaml::from_str("\"$$$triple\"").unwrap();
1072        assert_eq!(entry.path, "$$triple");
1073
1074        // Full form with multiple $
1075        let entry: BindingEntry = serde_yaml::from_str(
1076            r#"
1077            path: "$$escaped_var"
1078            "#,
1079        )
1080        .unwrap();
1081        assert_eq!(entry.path, "$escaped_var");
1082    }
1083
1084    #[test]
1085    fn test_binding_entry_deserialize_dollar_in_middle() {
1086        // $ in middle should be preserved
1087        let entry: BindingEntry = serde_yaml::from_str("\"task$name\"").unwrap();
1088        assert_eq!(entry.path, "task$name");
1089
1090        let entry: BindingEntry = serde_yaml::from_str("\"$task$name\"").unwrap();
1091        assert_eq!(entry.path, "task$name");
1092
1093        // Full form
1094        let entry: BindingEntry = serde_yaml::from_str(
1095            r#"
1096            path: "result$2"
1097            "#,
1098        )
1099        .unwrap();
1100        assert_eq!(entry.path, "result$2");
1101    }
1102
1103    #[test]
1104    fn test_binding_entry_deserialize_special_characters() {
1105        // Underscores and numbers (common in task IDs)
1106        let entry: BindingEntry = serde_yaml::from_str("\"$task_123\"").unwrap();
1107        assert_eq!(entry.path, "task_123");
1108
1109        let entry: BindingEntry = serde_yaml::from_str("\"$_private_task\"").unwrap();
1110        assert_eq!(entry.path, "_private_task");
1111
1112        // Hyphens
1113        let entry: BindingEntry = serde_yaml::from_str("\"$task-name\"").unwrap();
1114        assert_eq!(entry.path, "task-name");
1115
1116        // Mixed
1117        let entry: BindingEntry = serde_yaml::from_str("\"$task_1-result.field_2\"").unwrap();
1118        assert_eq!(entry.path, "task_1-result.field_2");
1119    }
1120
1121    #[test]
1122    fn test_binding_entry_deserialize_with_all_options() {
1123        // Full form with $ prefix and all options set
1124        let entry: BindingEntry = serde_yaml::from_str(
1125            r#"
1126            path: "$complex_task.nested.value"
1127            default: "default_value"
1128            lazy: true
1129            "#,
1130        )
1131        .unwrap();
1132        assert_eq!(entry.path, "complex_task.nested.value");
1133        assert_eq!(
1134            entry.default.as_ref().map(|v| v.as_str()),
1135            Some(Some("default_value"))
1136        );
1137        assert!(entry.lazy);
1138    }
1139
1140    #[test]
1141    fn test_binding_entry_equivalence_with_and_without_dollar() {
1142        // These should produce identical BindingEntry instances
1143        let with_dollar: BindingEntry = serde_yaml::from_str("\"$my_task\"").unwrap();
1144        let without_dollar: BindingEntry = serde_yaml::from_str("\"my_task\"").unwrap();
1145
1146        assert_eq!(with_dollar.path, without_dollar.path);
1147        assert_eq!(with_dollar.default, without_dollar.default);
1148        assert_eq!(with_dollar.lazy, without_dollar.lazy);
1149    }
1150
1151    #[test]
1152    fn test_binding_entry_deserialize_real_workflow_patterns() {
1153        // Pattern: Simple task reference
1154        let entry: BindingEntry = serde_yaml::from_str("\"$get_context\"").unwrap();
1155        assert_eq!(entry.path, "get_context");
1156
1157        // Pattern: Output field access
1158        let entry: BindingEntry = serde_yaml::from_str("\"$generate.content\"").unwrap();
1159        assert_eq!(entry.path, "generate.content");
1160
1161        // Pattern: Agent result access
1162        let entry: BindingEntry =
1163            serde_yaml::from_str("\"$research_agent.findings.summary\"").unwrap();
1164        assert_eq!(entry.path, "research_agent.findings.summary");
1165
1166        // Pattern: Lazy binding with default for optional task output
1167        let entry: BindingEntry = serde_yaml::from_str(
1168            r#"
1169            path: "$optional_step.result"
1170            default: null
1171            lazy: true
1172            "#,
1173        )
1174        .unwrap();
1175        assert_eq!(entry.path, "optional_step.result");
1176        assert_eq!(entry.default, Some(serde_json::Value::Null));
1177        assert!(entry.lazy);
1178    }
1179
1180    // ═══════════════════════════════════════════════════════════════
1181    // WithEntry -- parse_with_entry() tests
1182    // ═══════════════════════════════════════════════════════════════
1183
1184    #[test]
1185    fn with_parse_simple() {
1186        let entry = parse_with_entry("$step1").unwrap();
1187        assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
1188        assert_eq!(entry.default, None);
1189        assert_eq!(entry.transform, None);
1190        assert!(!entry.lazy);
1191        assert_eq!(entry.binding_type, BindingType::Any);
1192    }
1193
1194    #[test]
1195    fn with_parse_field_access() {
1196        let entry = parse_with_entry("$step1.output").unwrap();
1197        assert_eq!(entry.source, BindingPath::parse("$step1.output").unwrap());
1198        assert_eq!(entry.default, None);
1199        assert_eq!(entry.transform, None);
1200    }
1201
1202    #[test]
1203    fn with_parse_deep_path() {
1204        let entry = parse_with_entry("$step1.data.items[0].name").unwrap();
1205        assert_eq!(
1206            entry.source,
1207            BindingPath::parse("$step1.data.items[0].name").unwrap()
1208        );
1209    }
1210
1211    #[test]
1212    fn with_parse_default_string() {
1213        let entry = parse_with_entry(r#"$step1 ?? "fallback""#).unwrap();
1214        assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
1215        assert_eq!(entry.default, Some(json!("fallback")));
1216        assert_eq!(entry.transform, None);
1217    }
1218
1219    #[test]
1220    fn with_parse_default_number() {
1221        let entry = parse_with_entry("$step1 ?? 42").unwrap();
1222        assert_eq!(entry.default, Some(json!(42)));
1223    }
1224
1225    #[test]
1226    fn with_parse_default_float() {
1227        let entry = parse_with_entry("$step1.score ?? 0.5").unwrap();
1228        assert_eq!(entry.default, Some(json!(0.5)));
1229    }
1230
1231    #[test]
1232    fn with_parse_default_bool() {
1233        let entry = parse_with_entry("$step1.enabled ?? true").unwrap();
1234        assert_eq!(entry.default, Some(json!(true)));
1235    }
1236
1237    #[test]
1238    fn with_parse_default_null() {
1239        let entry = parse_with_entry("$step1.val ?? null").unwrap();
1240        assert_eq!(entry.default, Some(json!(null)));
1241    }
1242
1243    #[test]
1244    fn with_parse_default_array() {
1245        let entry = parse_with_entry("$step1 ?? []").unwrap();
1246        assert_eq!(entry.default, Some(json!([])));
1247    }
1248
1249    #[test]
1250    fn with_parse_default_object() {
1251        let entry = parse_with_entry(r#"$step1 ?? {"key": "val"}"#).unwrap();
1252        assert_eq!(entry.default, Some(json!({"key": "val"})));
1253    }
1254
1255    #[test]
1256    fn with_parse_transform_single() {
1257        let entry = parse_with_entry("$step1 | upper").unwrap();
1258        assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
1259        assert!(entry.transform.is_some());
1260        let t = entry.transform.unwrap();
1261        assert_eq!(t.ops.len(), 1);
1262        assert_eq!(t.ops[0], TransformOp::Upper);
1263    }
1264
1265    #[test]
1266    fn with_parse_transform_chain() {
1267        let entry = parse_with_entry("$step1.items | sort | unique").unwrap();
1268        let t = entry.transform.unwrap();
1269        assert_eq!(t.ops.len(), 2);
1270        assert_eq!(t.ops[0], TransformOp::Sort);
1271        assert_eq!(t.ops[1], TransformOp::Unique);
1272    }
1273
1274    #[test]
1275    fn with_parse_transform_with_args() {
1276        let entry = parse_with_entry("$step1.items | first(3)").unwrap();
1277        let t = entry.transform.unwrap();
1278        assert_eq!(t.ops.len(), 1);
1279        assert_eq!(t.ops[0], TransformOp::FirstN(3));
1280    }
1281
1282    #[test]
1283    fn with_parse_transform_and_default() {
1284        let entry = parse_with_entry("$step1.items | length ?? 0").unwrap();
1285        assert!(entry.transform.is_some());
1286        let t = entry.transform.unwrap();
1287        assert_eq!(t.ops.len(), 1);
1288        assert_eq!(t.ops[0], TransformOp::Length);
1289        assert_eq!(entry.default, Some(json!(0)));
1290    }
1291
1292    #[test]
1293    fn with_parse_full_chain_and_default() {
1294        let entry = parse_with_entry(r#"$step1.items | sort | first(3) ?? []"#).unwrap();
1295        let t = entry.transform.unwrap();
1296        assert_eq!(t.ops.len(), 2);
1297        assert_eq!(t.ops[0], TransformOp::Sort);
1298        assert_eq!(t.ops[1], TransformOp::FirstN(3));
1299        assert_eq!(entry.default, Some(json!([])));
1300    }
1301
1302    #[test]
1303    fn with_parse_context_ref() {
1304        let entry = parse_with_entry("$context.files.brand").unwrap();
1305        match &entry.source.source {
1306            BindingSource::Context(path) => assert_eq!(path.as_ref(), "files.brand"),
1307            _ => panic!("expected Context source"),
1308        }
1309    }
1310
1311    #[test]
1312    fn with_parse_input_ref() {
1313        let entry = parse_with_entry("$inputs.locale").unwrap();
1314        match &entry.source.source {
1315            BindingSource::Input(path) => assert_eq!(path.as_ref(), "locale"),
1316            _ => panic!("expected Input source"),
1317        }
1318    }
1319
1320    #[test]
1321    fn with_parse_env_ref() {
1322        let entry = parse_with_entry("$env.API_URL").unwrap();
1323        match &entry.source.source {
1324            BindingSource::Env(path) => assert_eq!(path.as_ref(), "API_URL"),
1325            _ => panic!("expected Env source"),
1326        }
1327    }
1328
1329    #[test]
1330    fn with_parse_whitespace_tolerance() {
1331        let entry = parse_with_entry("  $step1  |  upper  ").unwrap();
1332        assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
1333        let t = entry.transform.unwrap();
1334        assert_eq!(t.ops[0], TransformOp::Upper);
1335    }
1336
1337    #[test]
1338    fn with_parse_whitespace_around_default() {
1339        let entry = parse_with_entry("$step1  ??  42").unwrap();
1340        assert_eq!(entry.default, Some(json!(42)));
1341    }
1342
1343    // ═══════════════════════════════════════════════════════════════
1344    // WithEntry error cases
1345    // ═══════════════════════════════════════════════════════════════
1346
1347    #[test]
1348    fn with_parse_empty_string_error() {
1349        let result = parse_with_entry("");
1350        assert!(result.is_err());
1351        assert!(result.unwrap_err().reason.contains("empty"));
1352    }
1353
1354    #[test]
1355    fn with_parse_no_dollar_error() {
1356        let result = parse_with_entry("step1");
1357        assert!(result.is_err());
1358        let err = result.unwrap_err();
1359        assert!(err.reason.contains("must start with '$'"));
1360    }
1361
1362    #[test]
1363    fn with_parse_pipe_only_error() {
1364        let result = parse_with_entry("| upper");
1365        assert!(result.is_err());
1366    }
1367
1368    #[test]
1369    fn with_parse_default_only_error() {
1370        let result = parse_with_entry("?? 42");
1371        assert!(result.is_err());
1372    }
1373
1374    #[test]
1375    fn with_parse_trailing_pipe_error() {
1376        let result = parse_with_entry("$step1 |");
1377        assert!(result.is_err());
1378        assert!(result.unwrap_err().reason.contains("empty transform"));
1379    }
1380
1381    #[test]
1382    fn with_parse_invalid_json_default_error() {
1383        let result = parse_with_entry(r#"$step1 ?? {broken"#);
1384        assert!(result.is_err());
1385        assert!(result.unwrap_err().reason.contains("invalid default JSON"));
1386    }
1387
1388    #[test]
1389    fn with_parse_unquoted_string_default_error() {
1390        let result = parse_with_entry("$step1 ?? Anonymous");
1391        assert!(result.is_err());
1392    }
1393
1394    #[test]
1395    fn with_parse_empty_default_error() {
1396        // "$step1 ??" with nothing after
1397        let result = parse_with_entry("$step1 ??");
1398        assert!(result.is_err());
1399        assert!(result.unwrap_err().reason.contains("empty default"));
1400    }
1401
1402    #[test]
1403    fn with_parse_unknown_transform_error() {
1404        let result = parse_with_entry("$step1 | nonexistent_transform");
1405        assert!(result.is_err());
1406    }
1407
1408    // ═══════════════════════════════════════════════════════════════
1409    // WithEntry: default inside transform parens (edge case)
1410    // ═══════════════════════════════════════════════════════════════
1411
1412    #[test]
1413    fn with_parse_default_inside_parens_ignored() {
1414        // The ?? inside default() should NOT be treated as the default separator
1415        let entry = parse_with_entry(r#"$step1 | default("a ?? b")"#).unwrap();
1416        assert!(entry.transform.is_some());
1417        assert_eq!(entry.default, None); // No ?? outside parens
1418    }
1419
1420    #[test]
1421    fn with_parse_default_after_transform_with_inner_qq() {
1422        // default("a ?? b") ?? "fallback"
1423        let entry = parse_with_entry(r#"$step1 | default("a ?? b") ?? "fallback""#).unwrap();
1424        assert!(entry.transform.is_some());
1425        assert_eq!(entry.default, Some(json!("fallback")));
1426    }
1427
1428    // ═══════════════════════════════════════════════════════════════
1429    // WithEntry helpers
1430    // ═══════════════════════════════════════════════════════════════
1431
1432    #[test]
1433    fn with_entry_task_id() {
1434        let entry = parse_with_entry("$step1.data.name").unwrap();
1435        assert_eq!(entry.task_id(), Some("step1"));
1436    }
1437
1438    #[test]
1439    fn with_entry_task_id_context() {
1440        let entry = parse_with_entry("$context.files.brand").unwrap();
1441        assert_eq!(entry.task_id(), None);
1442    }
1443
1444    #[test]
1445    fn with_entry_simple_constructor() {
1446        let path = BindingPath::parse("$step1.data").unwrap();
1447        let entry = WithEntry::simple(path.clone());
1448        assert_eq!(entry.source, path);
1449        assert_eq!(entry.default, None);
1450        assert!(!entry.lazy);
1451        assert_eq!(entry.transform, None);
1452    }
1453
1454    #[test]
1455    fn with_entry_with_default_constructor() {
1456        let path = BindingPath::parse("$step1").unwrap();
1457        let entry = WithEntry::with_default(path.clone(), json!(42));
1458        assert_eq!(entry.source, path);
1459        assert_eq!(entry.default, Some(json!(42)));
1460    }
1461
1462    // ═══════════════════════════════════════════════════════════════
1463    // WithEntry YAML deserialization (string form)
1464    // ═══════════════════════════════════════════════════════════════
1465
1466    #[test]
1467    fn with_deser_string_simple() {
1468        let entry: WithEntry = serde_yaml::from_str("\"$step1\"").unwrap();
1469        assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
1470    }
1471
1472    #[test]
1473    fn with_deser_string_with_transform() {
1474        let entry: WithEntry = serde_yaml::from_str("\"$step1.name | upper\"").unwrap();
1475        assert_eq!(entry.source, BindingPath::parse("$step1.name").unwrap());
1476        let t = entry.transform.unwrap();
1477        assert_eq!(t.ops[0], TransformOp::Upper);
1478    }
1479
1480    #[test]
1481    fn with_deser_string_with_default() {
1482        let entry: WithEntry = serde_yaml::from_str(r#""$step1 ?? 42""#).unwrap();
1483        assert_eq!(entry.default, Some(json!(42)));
1484    }
1485
1486    // ═══════════════════════════════════════════════════════════════
1487    // WithEntry YAML deserialization (object form)
1488    // ═══════════════════════════════════════════════════════════════
1489
1490    #[test]
1491    fn with_deser_object_minimal() {
1492        let entry: WithEntry = serde_yaml::from_str(
1493            r#"
1494            from: "$step1"
1495            "#,
1496        )
1497        .unwrap();
1498        assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
1499        assert_eq!(entry.binding_type, BindingType::Any);
1500        assert_eq!(entry.default, None);
1501        assert!(!entry.lazy);
1502        assert_eq!(entry.transform, None);
1503    }
1504
1505    #[test]
1506    fn with_deser_object_typed() {
1507        let entry: WithEntry = serde_yaml::from_str(
1508            r#"
1509            from: "$step1.name"
1510            type: string
1511            "#,
1512        )
1513        .unwrap();
1514        assert_eq!(entry.binding_type, BindingType::String);
1515    }
1516
1517    #[test]
1518    fn with_deser_object_with_transform() {
1519        let entry: WithEntry = serde_yaml::from_str(
1520            r#"
1521            from: "$step1.text"
1522            transform: "upper | trim"
1523            "#,
1524        )
1525        .unwrap();
1526        let t = entry.transform.unwrap();
1527        assert_eq!(t.ops.len(), 2);
1528        assert_eq!(t.ops[0], TransformOp::Upper);
1529        assert_eq!(t.ops[1], TransformOp::Trim);
1530    }
1531
1532    #[test]
1533    fn with_deser_object_full() {
1534        let entry: WithEntry = serde_yaml::from_str(
1535            r#"
1536            from: "$step1.abstract"
1537            type: string
1538            transform: "lower | trim"
1539            default: "No abstract"
1540            lazy: true
1541            "#,
1542        )
1543        .unwrap();
1544        assert_eq!(entry.source, BindingPath::parse("$step1.abstract").unwrap());
1545        assert_eq!(entry.binding_type, BindingType::String);
1546        assert!(entry.transform.is_some());
1547        assert_eq!(entry.default, Some(json!("No abstract")));
1548        assert!(entry.lazy);
1549    }
1550
1551    #[test]
1552    fn with_deser_object_lazy() {
1553        let entry: WithEntry = serde_yaml::from_str(
1554            r#"
1555            from: "$step1.result"
1556            lazy: true
1557            "#,
1558        )
1559        .unwrap();
1560        assert!(entry.lazy);
1561    }
1562
1563    // ═══════════════════════════════════════════════════════════════
1564    // WithSpec deserialization (YAML map)
1565    // ═══════════════════════════════════════════════════════════════
1566
1567    #[test]
1568    fn with_deser_spec_empty() {
1569        let spec: WithSpec = serde_yaml::from_str("{}").unwrap();
1570        assert!(spec.is_empty());
1571    }
1572
1573    #[test]
1574    fn with_deser_spec_single() {
1575        let spec: WithSpec = serde_yaml::from_str(r#"result: "$step1""#).unwrap();
1576        assert_eq!(spec.len(), 1);
1577        let entry = spec.get("result").unwrap();
1578        assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
1579    }
1580
1581    #[test]
1582    fn with_deser_spec_mixed() {
1583        let yaml = r#"
1584result: "$step1"
1585title: "$step1.title | upper"
1586summary:
1587  from: "$step1.abstract"
1588  type: string
1589  transform: "lower | trim"
1590  default: "N/A"
1591  lazy: true
1592"#;
1593        let spec: WithSpec = serde_yaml::from_str(yaml).unwrap();
1594        assert_eq!(spec.len(), 3);
1595
1596        // String form: simple ref
1597        let result = spec.get("result").unwrap();
1598        assert_eq!(result.source, BindingPath::parse("$step1").unwrap());
1599        assert_eq!(result.transform, None);
1600
1601        // String form: with transform
1602        let title = spec.get("title").unwrap();
1603        assert_eq!(title.source, BindingPath::parse("$step1.title").unwrap());
1604        let t = title.transform.as_ref().unwrap();
1605        assert_eq!(t.ops[0], TransformOp::Upper);
1606
1607        // Object form: full
1608        let summary = spec.get("summary").unwrap();
1609        assert_eq!(
1610            summary.source,
1611            BindingPath::parse("$step1.abstract").unwrap()
1612        );
1613        assert_eq!(summary.binding_type, BindingType::String);
1614        assert!(summary.lazy);
1615        assert_eq!(summary.default, Some(json!("N/A")));
1616    }
1617
1618    // ═══════════════════════════════════════════════════════════════
1619    // WithEntry object form error cases
1620    // ═══════════════════════════════════════════════════════════════
1621
1622    #[test]
1623    fn with_deser_object_missing_from_error() {
1624        let result: Result<WithEntry, _> = serde_yaml::from_str(
1625            r#"
1626            type: string
1627            "#,
1628        );
1629        assert!(result.is_err());
1630    }
1631
1632    #[test]
1633    fn with_deser_object_invalid_path_error() {
1634        let result: Result<WithEntry, _> = serde_yaml::from_str(
1635            r#"
1636            from: "step1"
1637            "#,
1638        );
1639        // No $ prefix -> error
1640        assert!(result.is_err());
1641    }
1642
1643    #[test]
1644    fn with_deser_object_invalid_transform_error() {
1645        let result: Result<WithEntry, _> = serde_yaml::from_str(
1646            r#"
1647            from: "$step1"
1648            transform: "nonexistent_op"
1649            "#,
1650        );
1651        assert!(result.is_err());
1652    }
1653
1654    // ═══════════════════════════════════════════════════════════════
1655    // split_default() edge cases
1656    // ═══════════════════════════════════════════════════════════════
1657
1658    #[test]
1659    fn split_default_no_default() {
1660        let (path, def) = split_default("$step1 | upper").unwrap();
1661        assert_eq!(path, "$step1 | upper");
1662        assert_eq!(def, None);
1663    }
1664
1665    #[test]
1666    fn split_default_simple() {
1667        let (path, def) = split_default("$step1 ?? 42").unwrap();
1668        assert_eq!(path, "$step1");
1669        assert_eq!(def, Some("42"));
1670    }
1671
1672    #[test]
1673    fn split_default_inside_parens_ignored() {
1674        let (path, def) = split_default(r#"$step1 | default("a ?? b")"#).unwrap();
1675        assert_eq!(path, r#"$step1 | default("a ?? b")"#);
1676        assert_eq!(def, None);
1677    }
1678
1679    #[test]
1680    fn split_default_after_parens() {
1681        let (path, def) = split_default(r#"$step1 | default("inner") ?? "outer""#).unwrap();
1682        assert_eq!(path, r#"$step1 | default("inner")"#);
1683        assert_eq!(def, Some(r#""outer""#));
1684    }
1685
1686    // ═══════════════════════════════════════════════════════════════
1687    // split_transforms() edge cases
1688    // ═══════════════════════════════════════════════════════════════
1689
1690    #[test]
1691    fn split_transforms_no_pipe() {
1692        let (path, t) = split_transforms("$step1");
1693        assert_eq!(path, "$step1");
1694        assert_eq!(t, None);
1695    }
1696
1697    #[test]
1698    fn split_transforms_single() {
1699        let (path, t) = split_transforms("$step1 | upper");
1700        assert_eq!(path, "$step1 ");
1701        assert_eq!(t, Some(" upper"));
1702    }
1703
1704    #[test]
1705    fn split_transforms_chain() {
1706        // First pipe splits path from rest of transforms
1707        let (path, t) = split_transforms("$step1 | sort | unique");
1708        assert_eq!(path, "$step1 ");
1709        assert_eq!(t, Some(" sort | unique"));
1710    }
1711
1712    // ═══════════════════════════════════════════════════════════════
1713    // WithEntryParseError display
1714    // ═══════════════════════════════════════════════════════════════
1715
1716    #[test]
1717    fn with_entry_parse_error_display() {
1718        let err = WithEntryParseError {
1719            input: "$bad".to_string(),
1720            reason: "test error".to_string(),
1721        };
1722        let msg = err.to_string();
1723        assert!(msg.contains("NIKA-155"));
1724        assert!(msg.contains("$bad"));
1725        assert!(msg.contains("test error"));
1726    }
1727
1728    // ═══════════════════════════════════════════════════════════════
1729    // WithEntry: binding pattern tests
1730    // ═══════════════════════════════════════════════════════════════
1731
1732    #[test]
1733    fn with_simple_path() {
1734        let entry = parse_with_entry("$step1").unwrap();
1735        assert_eq!(entry.task_id(), Some("step1"));
1736    }
1737
1738    #[test]
1739    fn with_deep_path() {
1740        let entry = parse_with_entry("$step1.data.name").unwrap();
1741        assert_eq!(entry.task_id(), Some("step1"));
1742        assert_eq!(entry.source.segments.len(), 2);
1743    }
1744
1745    #[test]
1746    fn with_default_string() {
1747        let entry = parse_with_entry(r#"$step1 ?? "N/A""#).unwrap();
1748        assert_eq!(entry.default, Some(json!("N/A")));
1749    }
1750}