Skip to main content

nika_core/binding/
transform.rs

1//! Transform Engine
2//!
3//! Pipeline transforms applied to binding values.
4//! Transforms are chained with `|` pipes: `sort | unique | first(3)`
5//!
6//! # Categories
7//!
8//! | Category | Ops |
9//! |----------|-----|
10//! | String | `upper`, `lower`, `trim`, `trim_start`, `trim_end` |
11//! | Collection | `length`, `first`, `last`, `first(N)`, `last(N)`, `keys`, `values`, `flatten`, `reverse`, `sort`, `unique`, `compact` |
12//! | Type conversion | `to_string`, `to_number`, `to_bool`, `to_json`, `parse_json` |
13//! | Numeric | `round(N)`, `abs`, `ceil`, `floor` |
14//! | Utility | `default(V)`, `type_of`, `join(S)`, `split(S)`, `shell` |
15//!
16//! # Null Handling
17//!
18//! - **Propagating**: null in → null out (`length`, `keys`, `type_of`, `to_string`, `to_json`)
19//! - **Failing**: null in → NIKA-153 error (`upper`, `lower`, `sort`, etc.)
20//! - Use `default()` or `??` to handle nulls safely
21
22use serde_json::Value;
23use smallvec::SmallVec;
24use std::fmt;
25
26/// A single transform operation
27#[derive(Debug, Clone, PartialEq)]
28pub enum TransformOp {
29    // -- String --
30    Upper,
31    Lower,
32    Trim,
33    TrimStart,
34    TrimEnd,
35
36    // -- Collection --
37    Length,
38    First,
39    Last,
40    FirstN(usize),
41    LastN(usize),
42    Keys,
43    Values,
44    Flatten,
45    Reverse,
46    Sort,
47    Unique,
48    Compact, // remove nulls
49
50    // -- Type conversion --
51    ToString,
52    ToNumber,
53    ToBool,
54    ToJson,
55    ParseJson,
56
57    // -- Numeric --
58    Round(Option<u32>),
59    Abs,
60    Ceil,
61    Floor,
62
63    // -- Utility --
64    Default(Value),
65    TypeOf,
66    Join(String),
67    Split(String),
68    Shell,
69}
70
71/// A chain of transform operations: `sort | unique | first(3)`
72#[derive(Debug, Clone, PartialEq)]
73pub struct TransformExpr {
74    pub ops: SmallVec<[TransformOp; 2]>,
75}
76
77/// Error parsing a transform expression (NIKA-151)
78#[derive(Debug, Clone, PartialEq)]
79pub struct TransformParseError {
80    pub input: String,
81    pub reason: String,
82}
83
84impl fmt::Display for TransformParseError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(
87            f,
88            "[NIKA-151] Transform parse error in '{}': {}",
89            self.input, self.reason
90        )
91    }
92}
93
94impl std::error::Error for TransformParseError {}
95
96/// Error applying a transform (NIKA-152 type mismatch, NIKA-153 null input)
97#[derive(Debug, Clone, PartialEq)]
98pub enum TransformError {
99    /// NIKA-152: Type mismatch
100    TypeMismatch {
101        op: &'static str,
102        expected: &'static str,
103        got: String,
104    },
105    /// NIKA-153: Null input on a failing transform
106    NullInput { op: &'static str },
107}
108
109impl fmt::Display for TransformError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            TransformError::TypeMismatch { op, expected, got } => {
113                write!(
114                    f,
115                    "[NIKA-152] Transform '{}' failed: expected {}, got {}",
116                    op, expected, got
117                )
118            }
119            TransformError::NullInput { op } => {
120                write!(
121                    f,
122                    "[NIKA-153] Transform '{}' received null — use default() to handle",
123                    op
124                )
125            }
126        }
127    }
128}
129
130impl std::error::Error for TransformError {}
131
132// ═══════════════════════════════════════════════════════════════
133// TransformExpr
134// ═══════════════════════════════════════════════════════════════
135
136impl TransformExpr {
137    /// Parse a pipe-separated transform expression.
138    ///
139    /// Examples: `"sort | unique | first(3)"`, `"upper"`, `""`
140    pub fn parse(input: &str) -> Result<Self, TransformParseError> {
141        let trimmed = input.trim();
142        if trimmed.is_empty() {
143            return Ok(TransformExpr {
144                ops: SmallVec::new(),
145            });
146        }
147
148        let ops: SmallVec<[TransformOp; 2]> = trimmed
149            .split('|')
150            .map(str::trim)
151            .filter(|s| !s.is_empty())
152            .map(|s| parse_single_op(s, input))
153            .collect::<Result<_, _>>()?;
154
155        Ok(TransformExpr { ops })
156    }
157
158    /// Apply all transforms in sequence to a value.
159    pub fn apply(&self, value: &Value) -> Result<Value, TransformError> {
160        let mut current = value.clone();
161        for op in &self.ops {
162            current = op.apply(&current)?;
163        }
164        Ok(current)
165    }
166
167    /// Returns true if this expression is empty (no-op).
168    pub fn is_empty(&self) -> bool {
169        self.ops.is_empty()
170    }
171}
172
173// ═══════════════════════════════════════════════════════════════
174// TransformOp::apply
175// ═══════════════════════════════════════════════════════════════
176
177impl TransformOp {
178    /// Apply this single transform to a JSON value.
179    pub fn apply(&self, value: &Value) -> Result<Value, TransformError> {
180        match self {
181            // ── String ───────────────────────────────────────
182            TransformOp::Upper => match value {
183                Value::Null => Err(TransformError::NullInput { op: "upper" }),
184                Value::String(s) => Ok(Value::String(s.to_uppercase())),
185                _ => Err(type_mismatch("upper", "string", value)),
186            },
187            TransformOp::Lower => match value {
188                Value::Null => Err(TransformError::NullInput { op: "lower" }),
189                Value::String(s) => Ok(Value::String(s.to_lowercase())),
190                _ => Err(type_mismatch("lower", "string", value)),
191            },
192            TransformOp::Trim => match value {
193                Value::Null => Err(TransformError::NullInput { op: "trim" }),
194                Value::String(s) => Ok(Value::String(s.trim().to_string())),
195                _ => Err(type_mismatch("trim", "string", value)),
196            },
197            TransformOp::TrimStart => match value {
198                Value::Null => Err(TransformError::NullInput { op: "trim_start" }),
199                Value::String(s) => Ok(Value::String(s.trim_start().to_string())),
200                _ => Err(type_mismatch("trim_start", "string", value)),
201            },
202            TransformOp::TrimEnd => match value {
203                Value::Null => Err(TransformError::NullInput { op: "trim_end" }),
204                Value::String(s) => Ok(Value::String(s.trim_end().to_string())),
205                _ => Err(type_mismatch("trim_end", "string", value)),
206            },
207
208            // ── Collection ───────────────────────────────────
209            TransformOp::Length => match value {
210                Value::Null => Ok(Value::Null), // propagating
211                Value::Array(arr) => Ok(Value::Number(arr.len().into())),
212                Value::String(s) => Ok(Value::Number(s.chars().count().into())),
213                Value::Object(obj) => Ok(Value::Number(obj.len().into())),
214                _ => Err(type_mismatch("length", "array, string, or object", value)),
215            },
216            TransformOp::First => match value {
217                Value::Null => Err(TransformError::NullInput { op: "first" }),
218                Value::Array(arr) => Ok(arr.first().cloned().unwrap_or(Value::Null)),
219                _ => Err(type_mismatch("first", "array", value)),
220            },
221            TransformOp::Last => match value {
222                Value::Null => Err(TransformError::NullInput { op: "last" }),
223                Value::Array(arr) => Ok(arr.last().cloned().unwrap_or(Value::Null)),
224                _ => Err(type_mismatch("last", "array", value)),
225            },
226            TransformOp::FirstN(n) => match value {
227                Value::Null => Err(TransformError::NullInput { op: "first" }),
228                Value::Array(arr) => {
229                    let taken: Vec<Value> = arr.iter().take(*n).cloned().collect();
230                    Ok(Value::Array(taken))
231                }
232                Value::String(s) => {
233                    // Truncate string to N characters
234                    let truncated: String = s.chars().take(*n).collect();
235                    Ok(Value::String(truncated))
236                }
237                Value::Object(_) => {
238                    // Serialize object to JSON string and truncate to N characters
239                    let json = serde_json::to_string(value).unwrap_or_default();
240                    let truncated: String = json.chars().take(*n).collect();
241                    Ok(Value::String(truncated))
242                }
243                _ => Err(type_mismatch("first", "array, string, or object", value)),
244            },
245            TransformOp::LastN(n) => match value {
246                Value::Null => Err(TransformError::NullInput { op: "last" }),
247                Value::Array(arr) => {
248                    let skip = arr.len().saturating_sub(*n);
249                    let taken: Vec<Value> = arr.iter().skip(skip).cloned().collect();
250                    Ok(Value::Array(taken))
251                }
252                _ => Err(type_mismatch("last", "array", value)),
253            },
254            TransformOp::Keys => match value {
255                Value::Null => Ok(Value::Null), // propagating
256                Value::Object(obj) => {
257                    let keys: Vec<Value> = obj.keys().map(|k| Value::String(k.clone())).collect();
258                    Ok(Value::Array(keys))
259                }
260                _ => Err(type_mismatch("keys", "object", value)),
261            },
262            TransformOp::Values => match value {
263                Value::Null => Err(TransformError::NullInput { op: "values" }),
264                Value::Object(obj) => {
265                    let vals: Vec<Value> = obj.values().cloned().collect();
266                    Ok(Value::Array(vals))
267                }
268                _ => Err(type_mismatch("values", "object", value)),
269            },
270            TransformOp::Flatten => match value {
271                Value::Null => Err(TransformError::NullInput { op: "flatten" }),
272                Value::Array(arr) => {
273                    let mut flat = Vec::new();
274                    for item in arr {
275                        match item {
276                            Value::Array(inner) => flat.extend(inner.iter().cloned()),
277                            other => flat.push(other.clone()),
278                        }
279                    }
280                    Ok(Value::Array(flat))
281                }
282                _ => Err(type_mismatch("flatten", "array", value)),
283            },
284            TransformOp::Reverse => match value {
285                Value::Null => Err(TransformError::NullInput { op: "reverse" }),
286                Value::Array(arr) => {
287                    let mut rev = arr.clone();
288                    rev.reverse();
289                    Ok(Value::Array(rev))
290                }
291                _ => Err(type_mismatch("reverse", "array", value)),
292            },
293            TransformOp::Sort => match value {
294                Value::Null => Err(TransformError::NullInput { op: "sort" }),
295                Value::Array(arr) => {
296                    let mut sorted = arr.clone();
297                    sorted.sort_by(|a, b| match (a.as_f64(), b.as_f64()) {
298                        (Some(x), Some(y)) => {
299                            x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal)
300                        }
301                        (Some(_), None) => std::cmp::Ordering::Less,
302                        (None, Some(_)) => std::cmp::Ordering::Greater,
303                        _ => a.to_string().cmp(&b.to_string()),
304                    });
305                    Ok(Value::Array(sorted))
306                }
307                _ => Err(type_mismatch("sort", "array", value)),
308            },
309            TransformOp::Unique => match value {
310                Value::Null => Err(TransformError::NullInput { op: "unique" }),
311                Value::Array(arr) => {
312                    let mut seen = Vec::new();
313                    let mut unique = Vec::new();
314                    for item in arr {
315                        let s = item.to_string();
316                        if !seen.contains(&s) {
317                            seen.push(s);
318                            unique.push(item.clone());
319                        }
320                    }
321                    Ok(Value::Array(unique))
322                }
323                _ => Err(type_mismatch("unique", "array", value)),
324            },
325            TransformOp::Compact => match value {
326                Value::Null => Err(TransformError::NullInput { op: "compact" }),
327                Value::Array(arr) => {
328                    let compacted: Vec<Value> =
329                        arr.iter().filter(|v| !v.is_null()).cloned().collect();
330                    Ok(Value::Array(compacted))
331                }
332                _ => Err(type_mismatch("compact", "array", value)),
333            },
334
335            // ── Type conversion ──────────────────────────────
336            TransformOp::ToString => match value {
337                Value::Null => Ok(Value::Null), // propagating
338                Value::String(_) => Ok(value.clone()),
339                Value::Number(n) => Ok(Value::String(n.to_string())),
340                Value::Bool(b) => Ok(Value::String(b.to_string())),
341                _ => Ok(Value::String(value.to_string())),
342            },
343            TransformOp::ToNumber => match value {
344                Value::Null => Err(TransformError::NullInput { op: "to_number" }),
345                Value::Number(_) => Ok(value.clone()),
346                Value::String(s) => {
347                    if let Ok(n) = s.parse::<i64>() {
348                        Ok(Value::Number(n.into()))
349                    } else if let Ok(f) = s.parse::<f64>() {
350                        Ok(serde_json::Number::from_f64(f)
351                            .map(Value::Number)
352                            .unwrap_or(Value::Null))
353                    } else {
354                        Err(TransformError::TypeMismatch {
355                            op: "to_number",
356                            expected: "numeric string",
357                            got: format!("\"{}\"", s),
358                        })
359                    }
360                }
361                Value::Bool(b) => Ok(Value::Number(if *b { 1 } else { 0 }.into())),
362                _ => Err(type_mismatch("to_number", "string, number, or bool", value)),
363            },
364            TransformOp::ToBool => match value {
365                Value::Null => Err(TransformError::NullInput { op: "to_bool" }),
366                Value::Bool(_) => Ok(value.clone()),
367                Value::Number(n) => Ok(Value::Bool(n.as_f64().map(|f| f != 0.0).unwrap_or(false))),
368                Value::String(s) => match s.as_str() {
369                    "true" | "1" | "yes" => Ok(Value::Bool(true)),
370                    "false" | "0" | "no" | "" => Ok(Value::Bool(false)),
371                    _ => Err(TransformError::TypeMismatch {
372                        op: "to_bool",
373                        expected: "truthy/falsy value",
374                        got: format!("\"{}\"", s),
375                    }),
376                },
377                _ => Err(type_mismatch("to_bool", "string, number, or bool", value)),
378            },
379            TransformOp::ToJson => match value {
380                Value::Null => Ok(Value::Null), // propagating
381                _ => Ok(Value::String(
382                    serde_json::to_string(value).unwrap_or_default(),
383                )),
384            },
385            TransformOp::ParseJson => match value {
386                Value::Null => Err(TransformError::NullInput { op: "parse_json" }),
387                Value::String(s) => {
388                    // Strip markdown code blocks: ```json\n...\n``` or ```\n...\n```
389                    let cleaned = strip_markdown_code_block(s);
390                    serde_json::from_str(&cleaned).map_err(|_| TransformError::TypeMismatch {
391                        op: "parse_json",
392                        expected: "valid JSON string",
393                        got: format!("\"{}\"", truncate(s, 50)),
394                    })
395                }
396                // Idempotent: already-parsed values pass through unchanged.
397                // This handles auto-parsed exec outputs where Nika converts
398                // JSON strings to values before transforms run.
399                Value::Array(_) | Value::Object(_) | Value::Number(_) | Value::Bool(_) => {
400                    Ok(value.clone())
401                }
402            },
403
404            // ── Numeric ──────────────────────────────────────
405            TransformOp::Round(decimals) => match value {
406                Value::Null => Err(TransformError::NullInput { op: "round" }),
407                Value::Number(n) => {
408                    let f = n.as_f64().unwrap_or(0.0);
409                    let d = decimals.unwrap_or(0);
410                    let factor = 10f64.powi(d as i32);
411                    let rounded = (f * factor).round() / factor;
412                    Ok(serde_json::Number::from_f64(rounded)
413                        .map(Value::Number)
414                        .unwrap_or(Value::Null))
415                }
416                _ => Err(type_mismatch("round", "number", value)),
417            },
418            TransformOp::Abs => match value {
419                Value::Null => Err(TransformError::NullInput { op: "abs" }),
420                Value::Number(n) => {
421                    if let Some(i) = n.as_i64() {
422                        Ok(Value::Number(i.unsigned_abs().into()))
423                    } else if let Some(f) = n.as_f64() {
424                        Ok(serde_json::Number::from_f64(f.abs())
425                            .map(Value::Number)
426                            .unwrap_or(Value::Null))
427                    } else {
428                        Ok(value.clone())
429                    }
430                }
431                _ => Err(type_mismatch("abs", "number", value)),
432            },
433            TransformOp::Ceil => match value {
434                Value::Null => Err(TransformError::NullInput { op: "ceil" }),
435                Value::Number(n) => {
436                    let f = n.as_f64().unwrap_or(0.0);
437                    Ok(Value::Number((f.ceil() as i64).into()))
438                }
439                _ => Err(type_mismatch("ceil", "number", value)),
440            },
441            TransformOp::Floor => match value {
442                Value::Null => Err(TransformError::NullInput { op: "floor" }),
443                Value::Number(n) => {
444                    let f = n.as_f64().unwrap_or(0.0);
445                    Ok(Value::Number((f.floor() as i64).into()))
446                }
447                _ => Err(type_mismatch("floor", "number", value)),
448            },
449
450            // ── Utility ──────────────────────────────────────
451            TransformOp::Default(default_val) => {
452                if value.is_null() {
453                    Ok(default_val.clone())
454                } else {
455                    Ok(value.clone())
456                }
457            }
458            TransformOp::TypeOf => {
459                let name = value_type_name(value);
460                Ok(Value::String(name.to_string()))
461            }
462            TransformOp::Join(sep) => match value {
463                Value::Null => Err(TransformError::NullInput { op: "join" }),
464                Value::Array(arr) => {
465                    let strings: Vec<String> = arr
466                        .iter()
467                        .map(|v| match v {
468                            Value::String(s) => s.clone(),
469                            other => other.to_string(),
470                        })
471                        .collect();
472                    Ok(Value::String(strings.join(sep)))
473                }
474                _ => Err(type_mismatch("join", "array", value)),
475            },
476            TransformOp::Split(sep) => match value {
477                Value::Null => Err(TransformError::NullInput { op: "split" }),
478                Value::String(s) => {
479                    let parts: Vec<Value> = s
480                        .split(sep.as_str())
481                        .map(|p| Value::String(p.to_string()))
482                        .collect();
483                    Ok(Value::Array(parts))
484                }
485                _ => Err(type_mismatch("split", "string", value)),
486            },
487            TransformOp::Shell => {
488                // Shell escaping — all types get escaped, not just strings
489                match value {
490                    Value::String(s) => Ok(Value::String(shell_escape(s))),
491                    _ => Ok(Value::String(shell_escape(&value.to_string()))),
492                }
493            }
494        }
495    }
496}
497
498// ═══════════════════════════════════════════════════════════════
499// Pipe Parser
500// ═══════════════════════════════════════════════════════════════
501
502/// Parse a single transform op from string.
503///
504/// Examples: `"upper"`, `"first(3)"`, `"join(', ')"`, `"default('N/A')"`, `"round(2)"`
505fn parse_single_op(input: &str, full_input: &str) -> Result<TransformOp, TransformParseError> {
506    let trimmed = input.trim();
507
508    // Check for parameterized form: name(arg)
509    if let Some(paren_pos) = trimmed.find('(') {
510        let name = trimmed[..paren_pos].trim();
511        let rest = &trimmed[paren_pos + 1..];
512        let arg = rest
513            .strip_suffix(')')
514            .ok_or_else(|| TransformParseError {
515                input: full_input.to_string(),
516                reason: format!("unclosed parenthesis in '{}'", trimmed),
517            })?
518            .trim();
519
520        match name {
521            "first" => {
522                let n: usize = arg.parse().map_err(|_| TransformParseError {
523                    input: full_input.to_string(),
524                    reason: format!("invalid argument for first(): '{}'", arg),
525                })?;
526                Ok(TransformOp::FirstN(n))
527            }
528            "last" => {
529                let n: usize = arg.parse().map_err(|_| TransformParseError {
530                    input: full_input.to_string(),
531                    reason: format!("invalid argument for last(): '{}'", arg),
532                })?;
533                Ok(TransformOp::LastN(n))
534            }
535            "round" => {
536                let d: u32 = arg.parse().map_err(|_| TransformParseError {
537                    input: full_input.to_string(),
538                    reason: format!("invalid argument for round(): '{}'", arg),
539                })?;
540                Ok(TransformOp::Round(Some(d)))
541            }
542            "join" => {
543                let sep = strip_quotes(arg);
544                Ok(TransformOp::Join(sep.to_string()))
545            }
546            "split" => {
547                let sep = strip_quotes(arg);
548                Ok(TransformOp::Split(sep.to_string()))
549            }
550            "default" => {
551                let val = parse_default_value(arg).map_err(|reason| TransformParseError {
552                    input: full_input.to_string(),
553                    reason,
554                })?;
555                Ok(TransformOp::Default(val))
556            }
557            _ => Err(TransformParseError {
558                input: full_input.to_string(),
559                reason: format!("unknown transform: '{}'", name),
560            }),
561        }
562    } else {
563        // Simple name (no args)
564        match trimmed {
565            "upper" => Ok(TransformOp::Upper),
566            "lower" => Ok(TransformOp::Lower),
567            "trim" => Ok(TransformOp::Trim),
568            "trim_start" => Ok(TransformOp::TrimStart),
569            "trim_end" => Ok(TransformOp::TrimEnd),
570            "length" => Ok(TransformOp::Length),
571            "first" => Ok(TransformOp::First),
572            "last" => Ok(TransformOp::Last),
573            "keys" => Ok(TransformOp::Keys),
574            "values" => Ok(TransformOp::Values),
575            "flatten" => Ok(TransformOp::Flatten),
576            "reverse" => Ok(TransformOp::Reverse),
577            "sort" => Ok(TransformOp::Sort),
578            "unique" => Ok(TransformOp::Unique),
579            "compact" => Ok(TransformOp::Compact),
580            "to_string" => Ok(TransformOp::ToString),
581            "to_number" => Ok(TransformOp::ToNumber),
582            "to_bool" => Ok(TransformOp::ToBool),
583            "to_json" => Ok(TransformOp::ToJson),
584            "parse_json" => Ok(TransformOp::ParseJson),
585            "round" => Ok(TransformOp::Round(None)),
586            "abs" => Ok(TransformOp::Abs),
587            "ceil" => Ok(TransformOp::Ceil),
588            "floor" => Ok(TransformOp::Floor),
589            "type_of" => Ok(TransformOp::TypeOf),
590            "shell" => Ok(TransformOp::Shell),
591            _ => Err(TransformParseError {
592                input: full_input.to_string(),
593                reason: format!("unknown transform: '{}'", trimmed),
594            }),
595        }
596    }
597}
598
599// ═══════════════════════════════════════════════════════════════
600// Helpers
601// ═══════════════════════════════════════════════════════════════
602
603/// Get a human-readable type name for a JSON value.
604fn value_type_name(value: &Value) -> &'static str {
605    match value {
606        Value::Null => "null",
607        Value::Bool(_) => "boolean",
608        Value::Number(_) => "number",
609        Value::String(_) => "string",
610        Value::Array(_) => "array",
611        Value::Object(_) => "object",
612    }
613}
614
615/// Helper to create TypeMismatch errors.
616fn type_mismatch(op: &'static str, expected: &'static str, got: &Value) -> TransformError {
617    TransformError::TypeMismatch {
618        op,
619        expected,
620        got: value_type_name(got).to_string(),
621    }
622}
623
624/// Strip surrounding quotes (single or double) from a string argument.
625fn strip_quotes(s: &str) -> &str {
626    let trimmed = s.trim();
627    if (trimmed.starts_with('\'') && trimmed.ends_with('\''))
628        || (trimmed.starts_with('"') && trimmed.ends_with('"'))
629    {
630        &trimmed[1..trimmed.len() - 1]
631    } else {
632        trimmed
633    }
634}
635
636/// Parse a default value argument: string, number, bool, null, or JSON.
637fn parse_default_value(arg: &str) -> Result<Value, String> {
638    let trimmed = arg.trim();
639
640    // Quoted string
641    if (trimmed.starts_with('\'') && trimmed.ends_with('\''))
642        || (trimmed.starts_with('"') && trimmed.ends_with('"'))
643    {
644        return Ok(Value::String(trimmed[1..trimmed.len() - 1].to_string()));
645    }
646
647    // null
648    if trimmed == "null" {
649        return Ok(Value::Null);
650    }
651
652    // boolean
653    if trimmed == "true" {
654        return Ok(Value::Bool(true));
655    }
656    if trimmed == "false" {
657        return Ok(Value::Bool(false));
658    }
659
660    // integer
661    if let Ok(n) = trimmed.parse::<i64>() {
662        return Ok(Value::Number(n.into()));
663    }
664
665    // float
666    if let Ok(f) = trimmed.parse::<f64>() {
667        if let Some(n) = serde_json::Number::from_f64(f) {
668            return Ok(Value::Number(n));
669        }
670    }
671
672    // JSON object or array
673    if (trimmed.starts_with('{') && trimmed.ends_with('}'))
674        || (trimmed.starts_with('[') && trimmed.ends_with(']'))
675    {
676        return serde_json::from_str(trimmed).map_err(|e| format!("invalid JSON default: {}", e));
677    }
678
679    // Bare string (unquoted) — treat as string
680    Ok(Value::String(trimmed.to_string()))
681}
682
683/// Truncate a string for error messages (UTF-8 safe).
684fn truncate(s: &str, max: usize) -> String {
685    if s.len() <= max {
686        s.to_string()
687    } else {
688        // Find a valid UTF-8 char boundary at or before `max`
689        let mut end = max;
690        while end > 0 && !s.is_char_boundary(end) {
691            end -= 1;
692        }
693        format!("{}...", &s[..end])
694    }
695}
696
697/// Strip markdown code block wrappers from a string.
698/// Handles: `` ```json\n...\n``` ``, `` ```\n...\n``` ``, and bare strings.
699fn strip_markdown_code_block(s: &str) -> String {
700    let trimmed = s.trim();
701    if trimmed.starts_with("```") {
702        // Find the end of the opening fence line
703        let after_fence = if let Some(newline_pos) = trimmed.find('\n') {
704            &trimmed[newline_pos + 1..]
705        } else {
706            return trimmed.to_string();
707        };
708        // Remove closing fence
709        if let Some(stripped) = after_fence.strip_suffix("```") {
710            stripped.trim().to_string()
711        } else {
712            after_fence.trim().to_string()
713        }
714    } else {
715        trimmed.to_string()
716    }
717}
718
719/// Shell-escape a string (single-quote wrapping).
720fn shell_escape(s: &str) -> String {
721    // Wrap in single quotes, escaping any internal single quotes
722    format!("'{}'", s.replace('\'', "'\\''"))
723}
724
725impl fmt::Display for TransformOp {
726    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
727        match self {
728            TransformOp::Upper => write!(f, "upper"),
729            TransformOp::Lower => write!(f, "lower"),
730            TransformOp::Trim => write!(f, "trim"),
731            TransformOp::TrimStart => write!(f, "trim_start"),
732            TransformOp::TrimEnd => write!(f, "trim_end"),
733            TransformOp::Length => write!(f, "length"),
734            TransformOp::First => write!(f, "first"),
735            TransformOp::Last => write!(f, "last"),
736            TransformOp::FirstN(n) => write!(f, "first({})", n),
737            TransformOp::LastN(n) => write!(f, "last({})", n),
738            TransformOp::Keys => write!(f, "keys"),
739            TransformOp::Values => write!(f, "values"),
740            TransformOp::Flatten => write!(f, "flatten"),
741            TransformOp::Reverse => write!(f, "reverse"),
742            TransformOp::Sort => write!(f, "sort"),
743            TransformOp::Unique => write!(f, "unique"),
744            TransformOp::Compact => write!(f, "compact"),
745            TransformOp::ToString => write!(f, "to_string"),
746            TransformOp::ToNumber => write!(f, "to_number"),
747            TransformOp::ToBool => write!(f, "to_bool"),
748            TransformOp::ToJson => write!(f, "to_json"),
749            TransformOp::ParseJson => write!(f, "parse_json"),
750            TransformOp::Round(None) => write!(f, "round"),
751            TransformOp::Round(Some(d)) => write!(f, "round({})", d),
752            TransformOp::Abs => write!(f, "abs"),
753            TransformOp::Ceil => write!(f, "ceil"),
754            TransformOp::Floor => write!(f, "floor"),
755            TransformOp::Default(v) => write!(f, "default({})", v),
756            TransformOp::TypeOf => write!(f, "type_of"),
757            TransformOp::Join(sep) => write!(f, "join('{}')", sep),
758            TransformOp::Split(sep) => write!(f, "split('{}')", sep),
759            TransformOp::Shell => write!(f, "shell"),
760        }
761    }
762}
763
764// ═══════════════════════════════════════════════════════════════
765// Tests
766// ═══════════════════════════════════════════════════════════════
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use serde_json::json;
772
773    // ─────────────────────────────────────────────────────────────
774    // Parse tests
775    // ─────────────────────────────────────────────────────────────
776
777    #[test]
778    fn parse_upper() {
779        let expr = TransformExpr::parse("upper").unwrap();
780        assert_eq!(expr.ops.as_slice(), &[TransformOp::Upper]);
781    }
782
783    #[test]
784    fn parse_lower() {
785        let expr = TransformExpr::parse("lower").unwrap();
786        assert_eq!(expr.ops.as_slice(), &[TransformOp::Lower]);
787    }
788
789    #[test]
790    fn parse_trim() {
791        let expr = TransformExpr::parse("trim").unwrap();
792        assert_eq!(expr.ops.as_slice(), &[TransformOp::Trim]);
793    }
794
795    #[test]
796    fn parse_length() {
797        let expr = TransformExpr::parse("length").unwrap();
798        assert_eq!(expr.ops.as_slice(), &[TransformOp::Length]);
799    }
800
801    #[test]
802    fn parse_first() {
803        let expr = TransformExpr::parse("first").unwrap();
804        assert_eq!(expr.ops.as_slice(), &[TransformOp::First]);
805    }
806
807    #[test]
808    fn parse_first_n() {
809        let expr = TransformExpr::parse("first(3)").unwrap();
810        assert_eq!(expr.ops.as_slice(), &[TransformOp::FirstN(3)]);
811    }
812
813    #[test]
814    fn parse_last_n() {
815        let expr = TransformExpr::parse("last(5)").unwrap();
816        assert_eq!(expr.ops.as_slice(), &[TransformOp::LastN(5)]);
817    }
818
819    #[test]
820    fn parse_join() {
821        let expr = TransformExpr::parse("join(', ')").unwrap();
822        assert_eq!(expr.ops.as_slice(), &[TransformOp::Join(", ".to_string())]);
823    }
824
825    #[test]
826    fn parse_split() {
827        let expr = TransformExpr::parse("split('/')").unwrap();
828        assert_eq!(expr.ops.as_slice(), &[TransformOp::Split("/".to_string())]);
829    }
830
831    #[test]
832    fn parse_default_string() {
833        let expr = TransformExpr::parse("default('N/A')").unwrap();
834        assert_eq!(
835            expr.ops.as_slice(),
836            &[TransformOp::Default(Value::String("N/A".to_string()))]
837        );
838    }
839
840    #[test]
841    fn parse_default_number() {
842        let expr = TransformExpr::parse("default(42)").unwrap();
843        assert_eq!(expr.ops.as_slice(), &[TransformOp::Default(json!(42))]);
844    }
845
846    #[test]
847    fn parse_round() {
848        let expr = TransformExpr::parse("round(2)").unwrap();
849        assert_eq!(expr.ops.as_slice(), &[TransformOp::Round(Some(2))]);
850    }
851
852    #[test]
853    fn parse_round_no_arg() {
854        let expr = TransformExpr::parse("round").unwrap();
855        assert_eq!(expr.ops.as_slice(), &[TransformOp::Round(None)]);
856    }
857
858    #[test]
859    fn parse_shell() {
860        let expr = TransformExpr::parse("shell").unwrap();
861        assert_eq!(expr.ops.as_slice(), &[TransformOp::Shell]);
862    }
863
864    #[test]
865    fn parse_to_json() {
866        let expr = TransformExpr::parse("to_json").unwrap();
867        assert_eq!(expr.ops.as_slice(), &[TransformOp::ToJson]);
868    }
869
870    #[test]
871    fn parse_parse_json() {
872        let expr = TransformExpr::parse("parse_json").unwrap();
873        assert_eq!(expr.ops.as_slice(), &[TransformOp::ParseJson]);
874    }
875
876    #[test]
877    fn parse_unknown() {
878        let err = TransformExpr::parse("bogus").unwrap_err();
879        assert!(err.reason.contains("unknown transform"));
880    }
881
882    #[test]
883    fn parse_pipeline() {
884        let expr = TransformExpr::parse("sort | unique | first(3)").unwrap();
885        assert_eq!(
886            expr.ops.as_slice(),
887            &[
888                TransformOp::Sort,
889                TransformOp::Unique,
890                TransformOp::FirstN(3),
891            ]
892        );
893    }
894
895    #[test]
896    fn parse_empty() {
897        let expr = TransformExpr::parse("").unwrap();
898        assert!(expr.is_empty());
899    }
900
901    #[test]
902    fn parse_single() {
903        let expr = TransformExpr::parse("upper").unwrap();
904        assert_eq!(expr.ops.len(), 1);
905    }
906
907    // ─────────────────────────────────────────────────────────────
908    // Apply tests — String
909    // ─────────────────────────────────────────────────────────────
910
911    #[test]
912    fn apply_upper_string() {
913        let result = TransformOp::Upper.apply(&json!("hello")).unwrap();
914        assert_eq!(result, json!("HELLO"));
915    }
916
917    #[test]
918    fn apply_upper_non_string() {
919        let err = TransformOp::Upper.apply(&json!(42)).unwrap_err();
920        assert!(matches!(err, TransformError::TypeMismatch { .. }));
921    }
922
923    #[test]
924    fn apply_upper_null() {
925        let err = TransformOp::Upper.apply(&Value::Null).unwrap_err();
926        assert!(matches!(err, TransformError::NullInput { .. }));
927    }
928
929    #[test]
930    fn apply_lower_string() {
931        let result = TransformOp::Lower.apply(&json!("HELLO")).unwrap();
932        assert_eq!(result, json!("hello"));
933    }
934
935    #[test]
936    fn apply_trim() {
937        let result = TransformOp::Trim.apply(&json!(" hello ")).unwrap();
938        assert_eq!(result, json!("hello"));
939    }
940
941    #[test]
942    fn apply_trim_start() {
943        let result = TransformOp::TrimStart.apply(&json!("  hello  ")).unwrap();
944        assert_eq!(result, json!("hello  "));
945    }
946
947    #[test]
948    fn apply_trim_end() {
949        let result = TransformOp::TrimEnd.apply(&json!("  hello  ")).unwrap();
950        assert_eq!(result, json!("  hello"));
951    }
952
953    // ─────────────────────────────────────────────────────────────
954    // Apply tests — Collection
955    // ─────────────────────────────────────────────────────────────
956
957    #[test]
958    fn apply_length_array() {
959        let result = TransformOp::Length.apply(&json!([1, 2, 3])).unwrap();
960        assert_eq!(result, json!(3));
961    }
962
963    #[test]
964    fn apply_length_string() {
965        let result = TransformOp::Length.apply(&json!("abc")).unwrap();
966        assert_eq!(result, json!(3));
967    }
968
969    #[test]
970    fn apply_length_object() {
971        let result = TransformOp::Length.apply(&json!({"a": 1, "b": 2})).unwrap();
972        assert_eq!(result, json!(2));
973    }
974
975    #[test]
976    fn apply_length_null() {
977        let result = TransformOp::Length.apply(&Value::Null).unwrap();
978        assert_eq!(result, Value::Null); // propagating
979    }
980
981    #[test]
982    fn apply_first_array() {
983        let result = TransformOp::First.apply(&json!([1, 2, 3])).unwrap();
984        assert_eq!(result, json!(1));
985    }
986
987    #[test]
988    fn apply_first_empty() {
989        let result = TransformOp::First.apply(&json!([])).unwrap();
990        assert_eq!(result, Value::Null);
991    }
992
993    #[test]
994    fn apply_last_array() {
995        let result = TransformOp::Last.apply(&json!([1, 2, 3])).unwrap();
996        assert_eq!(result, json!(3));
997    }
998
999    #[test]
1000    fn apply_first_n() {
1001        let result = TransformOp::FirstN(3)
1002            .apply(&json!([1, 2, 3, 4, 5]))
1003            .unwrap();
1004        assert_eq!(result, json!([1, 2, 3]));
1005    }
1006
1007    #[test]
1008    fn apply_last_n() {
1009        let result = TransformOp::LastN(2)
1010            .apply(&json!([1, 2, 3, 4, 5]))
1011            .unwrap();
1012        assert_eq!(result, json!([4, 5]));
1013    }
1014
1015    #[test]
1016    fn apply_keys() {
1017        let result = TransformOp::Keys.apply(&json!({"a": 1, "b": 2})).unwrap();
1018        // serde_json::Map preserves insertion order
1019        assert_eq!(result, json!(["a", "b"]));
1020    }
1021
1022    #[test]
1023    fn apply_keys_null() {
1024        let result = TransformOp::Keys.apply(&Value::Null).unwrap();
1025        assert_eq!(result, Value::Null); // propagating
1026    }
1027
1028    #[test]
1029    fn apply_values() {
1030        let result = TransformOp::Values.apply(&json!({"a": 1, "b": 2})).unwrap();
1031        assert_eq!(result, json!([1, 2]));
1032    }
1033
1034    #[test]
1035    fn apply_sort() {
1036        let result = TransformOp::Sort.apply(&json!([3, 1, 2])).unwrap();
1037        assert_eq!(result, json!([1, 2, 3]));
1038    }
1039
1040    #[test]
1041    fn apply_unique() {
1042        let result = TransformOp::Unique.apply(&json!([1, 2, 2, 3])).unwrap();
1043        assert_eq!(result, json!([1, 2, 3]));
1044    }
1045
1046    #[test]
1047    fn apply_compact() {
1048        let result = TransformOp::Compact
1049            .apply(&json!([1, null, 2, null]))
1050            .unwrap();
1051        assert_eq!(result, json!([1, 2]));
1052    }
1053
1054    #[test]
1055    fn apply_flatten() {
1056        let result = TransformOp::Flatten.apply(&json!([[1, 2], [3]])).unwrap();
1057        assert_eq!(result, json!([1, 2, 3]));
1058    }
1059
1060    #[test]
1061    fn apply_reverse() {
1062        let result = TransformOp::Reverse.apply(&json!([1, 2, 3])).unwrap();
1063        assert_eq!(result, json!([3, 2, 1]));
1064    }
1065
1066    // ─────────────────────────────────────────────────────────────
1067    // Apply tests — Type conversion
1068    // ─────────────────────────────────────────────────────────────
1069
1070    #[test]
1071    fn apply_to_string() {
1072        let result = TransformOp::ToString.apply(&json!(42)).unwrap();
1073        assert_eq!(result, json!("42"));
1074    }
1075
1076    #[test]
1077    fn apply_to_string_null() {
1078        let result = TransformOp::ToString.apply(&Value::Null).unwrap();
1079        assert_eq!(result, Value::Null); // propagating
1080    }
1081
1082    #[test]
1083    fn apply_to_number() {
1084        let result = TransformOp::ToNumber.apply(&json!("42")).unwrap();
1085        assert_eq!(result, json!(42));
1086    }
1087
1088    #[test]
1089    fn apply_to_number_float() {
1090        let result = TransformOp::ToNumber.apply(&json!("3.12")).unwrap();
1091        assert_eq!(result, json!(3.12));
1092    }
1093
1094    #[test]
1095    fn apply_to_bool_number() {
1096        assert_eq!(TransformOp::ToBool.apply(&json!(1)).unwrap(), json!(true));
1097        assert_eq!(TransformOp::ToBool.apply(&json!(0)).unwrap(), json!(false));
1098    }
1099
1100    #[test]
1101    fn apply_to_bool_string() {
1102        assert_eq!(
1103            TransformOp::ToBool.apply(&json!("true")).unwrap(),
1104            json!(true)
1105        );
1106        assert_eq!(
1107            TransformOp::ToBool.apply(&json!("false")).unwrap(),
1108            json!(false)
1109        );
1110    }
1111
1112    #[test]
1113    fn apply_to_json() {
1114        let result = TransformOp::ToJson.apply(&json!([1, 2])).unwrap();
1115        assert_eq!(result, json!("[1,2]"));
1116    }
1117
1118    #[test]
1119    fn apply_parse_json() {
1120        let result = TransformOp::ParseJson.apply(&json!(r#"{"a":1}"#)).unwrap();
1121        assert_eq!(result, json!({"a": 1}));
1122    }
1123
1124    // ─────────────────────────────────────────────────────────────
1125    // Apply tests — Numeric
1126    // ─────────────────────────────────────────────────────────────
1127
1128    #[test]
1129    fn apply_round() {
1130        let result = TransformOp::Round(Some(2)).apply(&json!(4.56789)).unwrap();
1131        assert_eq!(result, json!(4.57));
1132    }
1133
1134    #[test]
1135    fn apply_round_no_decimals() {
1136        let result = TransformOp::Round(None).apply(&json!(3.7)).unwrap();
1137        assert_eq!(result, json!(4.0));
1138    }
1139
1140    #[test]
1141    fn apply_abs() {
1142        let result = TransformOp::Abs.apply(&json!(-5)).unwrap();
1143        assert_eq!(result, json!(5));
1144    }
1145
1146    #[test]
1147    fn apply_abs_float() {
1148        let result = TransformOp::Abs.apply(&json!(-3.12)).unwrap();
1149        assert_eq!(result, json!(3.12));
1150    }
1151
1152    #[test]
1153    fn apply_ceil() {
1154        let result = TransformOp::Ceil.apply(&json!(3.2)).unwrap();
1155        assert_eq!(result, json!(4));
1156    }
1157
1158    #[test]
1159    fn apply_floor() {
1160        let result = TransformOp::Floor.apply(&json!(3.8)).unwrap();
1161        assert_eq!(result, json!(3));
1162    }
1163
1164    // ─────────────────────────────────────────────────────────────
1165    // Apply tests — Utility
1166    // ─────────────────────────────────────────────────────────────
1167
1168    #[test]
1169    fn apply_join() {
1170        let result = TransformOp::Join(", ".to_string())
1171            .apply(&json!(["a", "b"]))
1172            .unwrap();
1173        assert_eq!(result, json!("a, b"));
1174    }
1175
1176    #[test]
1177    fn apply_split() {
1178        let result = TransformOp::Split("/".to_string())
1179            .apply(&json!("a/b/c"))
1180            .unwrap();
1181        assert_eq!(result, json!(["a", "b", "c"]));
1182    }
1183
1184    #[test]
1185    fn apply_default_with_null() {
1186        let result = TransformOp::Default(json!("N/A"))
1187            .apply(&Value::Null)
1188            .unwrap();
1189        assert_eq!(result, json!("N/A"));
1190    }
1191
1192    #[test]
1193    fn apply_default_with_value() {
1194        let result = TransformOp::Default(json!("N/A"))
1195            .apply(&json!("hello"))
1196            .unwrap();
1197        assert_eq!(result, json!("hello"));
1198    }
1199
1200    #[test]
1201    fn apply_typeof() {
1202        assert_eq!(
1203            TransformOp::TypeOf.apply(&json!(42)).unwrap(),
1204            json!("number")
1205        );
1206        assert_eq!(
1207            TransformOp::TypeOf.apply(&json!("x")).unwrap(),
1208            json!("string")
1209        );
1210        assert_eq!(
1211            TransformOp::TypeOf.apply(&Value::Null).unwrap(),
1212            json!("null")
1213        );
1214        assert_eq!(
1215            TransformOp::TypeOf.apply(&json!(true)).unwrap(),
1216            json!("boolean")
1217        );
1218        assert_eq!(
1219            TransformOp::TypeOf.apply(&json!([1])).unwrap(),
1220            json!("array")
1221        );
1222        assert_eq!(
1223            TransformOp::TypeOf.apply(&json!({"a": 1})).unwrap(),
1224            json!("object")
1225        );
1226    }
1227
1228    #[test]
1229    fn apply_shell() {
1230        let result = TransformOp::Shell.apply(&json!("hello world")).unwrap();
1231        assert_eq!(result, json!("'hello world'"));
1232    }
1233
1234    // ─────────────────────────────────────────────────────────────
1235    // Pipeline tests
1236    // ─────────────────────────────────────────────────────────────
1237
1238    #[test]
1239    fn pipeline_sort_unique() {
1240        let expr = TransformExpr::parse("sort | unique").unwrap();
1241        let result = expr.apply(&json!([3, 1, 2, 1])).unwrap();
1242        assert_eq!(result, json!([1, 2, 3]));
1243    }
1244
1245    #[test]
1246    fn pipeline_sort_first_n() {
1247        let expr = TransformExpr::parse("sort | first(2)").unwrap();
1248        let result = expr.apply(&json!([3, 1, 2])).unwrap();
1249        assert_eq!(result, json!([1, 2]));
1250    }
1251
1252    #[test]
1253    fn pipeline_upper_trim() {
1254        let expr = TransformExpr::parse("trim | upper").unwrap();
1255        let result = expr.apply(&json!(" hello ")).unwrap();
1256        assert_eq!(result, json!("HELLO"));
1257    }
1258
1259    #[test]
1260    fn pipeline_empty() {
1261        let expr = TransformExpr::parse("").unwrap();
1262        let result = expr.apply(&json!("unchanged")).unwrap();
1263        assert_eq!(result, json!("unchanged"));
1264    }
1265
1266    #[test]
1267    fn pipeline_single() {
1268        let expr = TransformExpr::parse("upper").unwrap();
1269        assert_eq!(expr.ops.len(), 1);
1270    }
1271
1272    #[test]
1273    fn pipeline_default_then_upper() {
1274        let expr = TransformExpr::parse("default('unknown') | upper").unwrap();
1275        let result = expr.apply(&Value::Null).unwrap();
1276        assert_eq!(result, json!("UNKNOWN"));
1277    }
1278
1279    // ─────────────────────────────────────────────────────────────
1280    // Display
1281    // ─────────────────────────────────────────────────────────────
1282
1283    #[test]
1284    fn display_ops() {
1285        assert_eq!(TransformOp::Upper.to_string(), "upper");
1286        assert_eq!(TransformOp::FirstN(3).to_string(), "first(3)");
1287        assert_eq!(
1288            TransformOp::Join(", ".to_string()).to_string(),
1289            "join(', ')"
1290        );
1291        assert_eq!(TransformOp::Round(Some(2)).to_string(), "round(2)");
1292        assert_eq!(TransformOp::Round(None).to_string(), "round");
1293        assert_eq!(
1294            TransformOp::Default(json!("N/A")).to_string(),
1295            "default(\"N/A\")"
1296        );
1297    }
1298
1299    // ─────────────────────────────────────────────────────────────
1300    // Error display
1301    // ─────────────────────────────────────────────────────────────
1302
1303    #[test]
1304    fn error_display_parse() {
1305        let err = TransformParseError {
1306            input: "bogus".to_string(),
1307            reason: "unknown transform: 'bogus'".to_string(),
1308        };
1309        assert!(err.to_string().contains("NIKA-151"));
1310    }
1311
1312    #[test]
1313    fn error_display_type_mismatch() {
1314        let err = TransformError::TypeMismatch {
1315            op: "upper",
1316            expected: "string",
1317            got: "number".to_string(),
1318        };
1319        assert!(err.to_string().contains("NIKA-152"));
1320    }
1321
1322    #[test]
1323    fn error_display_null_input() {
1324        let err = TransformError::NullInput { op: "sort" };
1325        assert!(err.to_string().contains("NIKA-153"));
1326    }
1327
1328    // ─────────────────────────────────────────────────────────────
1329    // Edge cases
1330    // ─────────────────────────────────────────────────────────────
1331
1332    #[test]
1333    fn parse_default_bool() {
1334        let expr = TransformExpr::parse("default(true)").unwrap();
1335        assert_eq!(expr.ops.as_slice(), &[TransformOp::Default(json!(true))]);
1336    }
1337
1338    #[test]
1339    fn parse_default_null() {
1340        let expr = TransformExpr::parse("default(null)").unwrap();
1341        assert_eq!(expr.ops.as_slice(), &[TransformOp::Default(Value::Null)]);
1342    }
1343
1344    #[test]
1345    fn parse_default_array() {
1346        let expr = TransformExpr::parse("default([])").unwrap();
1347        assert_eq!(expr.ops.as_slice(), &[TransformOp::Default(json!([]))]);
1348    }
1349
1350    #[test]
1351    fn first_n_larger_than_array() {
1352        let result = TransformOp::FirstN(10).apply(&json!([1, 2, 3])).unwrap();
1353        assert_eq!(result, json!([1, 2, 3])); // takes what's available
1354    }
1355
1356    #[test]
1357    fn last_n_larger_than_array() {
1358        let result = TransformOp::LastN(10).apply(&json!([1, 2, 3])).unwrap();
1359        assert_eq!(result, json!([1, 2, 3]));
1360    }
1361
1362    #[test]
1363    fn flatten_mixed() {
1364        let result = TransformOp::Flatten
1365            .apply(&json!([[1, 2], 3, [4]]))
1366            .unwrap();
1367        assert_eq!(result, json!([1, 2, 3, 4]));
1368    }
1369
1370    #[test]
1371    fn unclosed_paren() {
1372        let err = TransformExpr::parse("first(3").unwrap_err();
1373        assert!(err.reason.contains("unclosed parenthesis"));
1374    }
1375
1376    #[test]
1377    fn join_mixed_types() {
1378        let result = TransformOp::Join(", ".to_string())
1379            .apply(&json!(["a", 1, true]))
1380            .unwrap();
1381        assert_eq!(result, json!("a, 1, true"));
1382    }
1383
1384    #[test]
1385    fn parse_json_invalid() {
1386        let err = TransformOp::ParseJson
1387            .apply(&json!("not json"))
1388            .unwrap_err();
1389        assert!(matches!(err, TransformError::TypeMismatch { .. }));
1390    }
1391
1392    #[test]
1393    fn to_number_invalid() {
1394        let err = TransformOp::ToNumber.apply(&json!("abc")).unwrap_err();
1395        assert!(matches!(err, TransformError::TypeMismatch { .. }));
1396    }
1397
1398    #[test]
1399    fn to_bool_invalid_string() {
1400        let err = TransformOp::ToBool.apply(&json!("maybe")).unwrap_err();
1401        assert!(matches!(err, TransformError::TypeMismatch { .. }));
1402    }
1403
1404    // ─────────────────────────────────────────────────────────────
1405    // Bug fix tests
1406    // ─────────────────────────────────────────────────────────────
1407
1408    #[test]
1409    fn first_n_on_object_serializes_and_truncates() {
1410        // BUG 3: first(N) on an object should serialize to JSON and truncate
1411        let obj = json!({"links": [1, 2, 3], "count": 3});
1412        let result = TransformOp::FirstN(10).apply(&obj).unwrap();
1413        // Should be a truncated JSON string
1414        assert!(result.is_string());
1415        let s = result.as_str().unwrap();
1416        assert_eq!(s.len(), 10);
1417    }
1418
1419    #[test]
1420    fn first_n_on_object_full() {
1421        // first(N) with N larger than JSON length returns full JSON
1422        let obj = json!({"a": 1});
1423        let result = TransformOp::FirstN(1000).apply(&obj).unwrap();
1424        assert!(result.is_string());
1425        assert_eq!(result.as_str().unwrap(), r#"{"a":1}"#);
1426    }
1427
1428    #[test]
1429    fn first_n_on_string_truncates() {
1430        let result = TransformOp::FirstN(5).apply(&json!("hello world")).unwrap();
1431        assert_eq!(result, json!("hello"));
1432    }
1433
1434    #[test]
1435    fn parse_json_idempotent_on_array() {
1436        // BUG 7: parse_json on an already-parsed array should be a no-op
1437        let arr = json!([1, 2, 3]);
1438        let result = TransformOp::ParseJson.apply(&arr).unwrap();
1439        assert_eq!(result, json!([1, 2, 3]));
1440    }
1441
1442    #[test]
1443    fn parse_json_idempotent_on_object() {
1444        // BUG 7: parse_json on an already-parsed object should be a no-op
1445        let obj = json!({"key": "value"});
1446        let result = TransformOp::ParseJson.apply(&obj).unwrap();
1447        assert_eq!(result, json!({"key": "value"}));
1448    }
1449
1450    #[test]
1451    fn parse_json_idempotent_on_number_and_bool() {
1452        // parse_json on auto-parsed primitives should be a no-op
1453        assert_eq!(TransformOp::ParseJson.apply(&json!(42)).unwrap(), json!(42));
1454        assert_eq!(
1455            TransformOp::ParseJson.apply(&json!(true)).unwrap(),
1456            json!(true)
1457        );
1458    }
1459
1460    #[test]
1461    fn parse_json_strips_markdown_code_block() {
1462        let input = json!("```json\n{\"name\": \"test\"}\n```");
1463        let result = TransformOp::ParseJson.apply(&input).unwrap();
1464        assert_eq!(result, json!({"name": "test"}));
1465    }
1466
1467    #[test]
1468    fn parse_json_strips_generic_code_block() {
1469        let input = json!("```\n[1, 2, 3]\n```");
1470        let result = TransformOp::ParseJson.apply(&input).unwrap();
1471        assert_eq!(result, json!([1, 2, 3]));
1472    }
1473
1474    #[test]
1475    fn parse_json_handles_bare_json() {
1476        let input = json!("{\"key\": \"value\"}");
1477        let result = TransformOp::ParseJson.apply(&input).unwrap();
1478        assert_eq!(result, json!({"key": "value"}));
1479    }
1480
1481    #[test]
1482    fn parse_json_strips_whitespace_around_code_block() {
1483        let input = json!("  ```json\n  [\"a\", \"b\"]\n  ```  ");
1484        let result = TransformOp::ParseJson.apply(&input).unwrap();
1485        assert_eq!(result, json!(["a", "b"]));
1486    }
1487
1488    #[test]
1489    fn to_json_then_length_returns_char_count() {
1490        // BUG 8: to_json | length should return string character count
1491        let obj = json!({"countries": ["FR", "US"]});
1492        let json_str = TransformOp::ToJson.apply(&obj).unwrap();
1493        assert!(json_str.is_string());
1494        let length = TransformOp::Length.apply(&json_str).unwrap();
1495        // Should be the character count of the JSON string, not 1
1496        assert!(length.as_u64().unwrap() > 1);
1497    }
1498
1499    /// Bug 30: |length must return character count, not byte count for Unicode.
1500    #[test]
1501    fn regression_bug30_length_unicode_chars_not_bytes() {
1502        // "日本語" is 3 characters but 9 bytes in UTF-8
1503        let result = TransformOp::Length.apply(&json!("日本語")).unwrap();
1504        assert_eq!(
1505            result,
1506            json!(3),
1507            "|length on Unicode string must count chars, not bytes"
1508        );
1509    }
1510
1511    /// Bug 30: additional Unicode edge cases for |length.
1512    #[test]
1513    fn regression_bug30_length_unicode_emoji() {
1514        // Emoji: "👋🌍" is 2 characters but 8 bytes
1515        let result = TransformOp::Length.apply(&json!("👋🌍")).unwrap();
1516        assert_eq!(result, json!(2), "|length on emoji string must count chars");
1517    }
1518
1519    /// Bug 30: |length on ASCII should remain unchanged.
1520    #[test]
1521    fn regression_bug30_length_ascii_unchanged() {
1522        let result = TransformOp::Length.apply(&json!("abc")).unwrap();
1523        assert_eq!(result, json!(3), "|length on ASCII string is still correct");
1524    }
1525
1526    /// Bug 46: |sort must use numeric ordering for numbers, not lexicographic.
1527    #[test]
1528    fn regression_bug46_sort_numeric_ordering() {
1529        let result = TransformOp::Sort.apply(&json!([1, 10, 2, 20, 3])).unwrap();
1530        assert_eq!(
1531            result,
1532            json!([1, 2, 3, 10, 20]),
1533            "|sort on numbers must use numeric ordering, not lexicographic"
1534        );
1535    }
1536
1537    /// Bug 46: |sort with mixed types (numbers and strings).
1538    #[test]
1539    fn regression_bug46_sort_mixed_types() {
1540        let result = TransformOp::Sort.apply(&json!([10, 2, "b", "a"])).unwrap();
1541        assert_eq!(result, json!([2, 10, "a", "b"]));
1542    }
1543
1544    /// Bug 46: |sort preserves string lexicographic ordering.
1545    #[test]
1546    fn regression_bug46_sort_strings_unchanged() {
1547        let result = TransformOp::Sort
1548            .apply(&json!(["banana", "apple", "cherry"]))
1549            .unwrap();
1550        assert_eq!(result, json!(["apple", "banana", "cherry"]));
1551    }
1552
1553    /// Bug 46: |sort with floats.
1554    #[test]
1555    fn regression_bug46_sort_floats() {
1556        let result = TransformOp::Sort
1557            .apply(&json!([1.5, 0.1, 2.3, 0.9]))
1558            .unwrap();
1559        assert_eq!(result, json!([0.1, 0.9, 1.5, 2.3]));
1560    }
1561}