Repository: dathere/qsv (language: rust)

Issue:
`luau`: additional helper functions
@ggrothendieck came up with an extensive list of helper functions to add to `luau` as qsv's DSL.

---

If you are implementing cumsum there are a number of other related functions that have proven to be useful in other languages which follow a similar pattern and so could be readily implemented at the same time.

**cumprod, cumany, cumall, cummax, cummin**
These are like cumsum but in place of + they use *, or, and, max amd min. cummean is also useful but does not fit exactly into the same pattern.

**accumulate**
Has three arguments. A column, a function of two arguments and an optional initial value. If   y = accum(x, f, init)then y[1] = init and for i >1 we have y[i] = f(y[i-1], x[i]). The default for init is x[1]. Note that if f is +, *, or, and, max or min we get the above cum... functions.

**Other**

The following are also useful and are related in so far as they also involve storing the previous value.

**lag**
It has three arguments. The column, how many positions to lag (default is 1) and in the case that the lag is off the front of the column use default. If y = lag(x, k, default) then y[i] = x[i-k] if i > k and default otherwise. Negative k could be considered too if not too hard to implement.
Recall we discussed enum by group with the shortest solution being the following where Name is the column to group by:
```
qsv luau map seq "x = (Name == prev and 1 or 0) * (x or 0) + 1; prev = Name; return x" file1.csv
```
With lag we could omit setting prev
```
qsv luau map seq "x = (Name == lag(Name, 1) and 1 or 0) * (x or 0) + 1; return x" file1.csv
```

**diff**
Same args as lag. Defined as x - lag(x, k, default)

_Originally posted by @ggrothendieck in https://github.com/jqnatividad/qsv/discussions/1760#discussioncomment-9262555_


Discussion / hints:
Do this once mlua 0.10.0 is released
https://github.com/mlua-rs/mlua/blob/main/docs/release_notes/v0.10.md

Fix patch:
diff --git a/src/cmd/luau.rs b/src/cmd/luau.rs
index e0dcba7b2..e0da289b6 100644
--- a/src/cmd/luau.rs
+++ b/src/cmd/luau.rs
@@ -238,6 +238,8 @@ Common options:
 "#;
 
 use std::{
+    cell::RefCell,
+    collections::HashMap,
     env, fs, io,
     io::Write,
     path::Path,
@@ -1762,6 +1764,71 @@ fn setup_helpers(
     )?;
     luau.globals().set("qsv_loadcsv", qsv_loadcsv)?;
 
+    // this is a helper function to load a JSON file into a Luau table.
+    //
+    //   qsv_loadjson(table_name: string, filepath: string)
+    //      table_name: the name of the Luau table to load the JSON data into.
+    //        filepath: the path of the JSON file to load
+    //         returns: true if successful.
+    //                  A Luau runtime error if the filepath is invalid or JSON parsing fails.
+    //
+    let qsv_loadjson =
+        luau.create_function(move |luau, (table_name, filepath): (String, String)| {
+            if filepath.is_empty() {
+                return helper_err!("qsv_loadjson", "filepath cannot be empty.");
+            }
+
+            let path = Path::new(&filepath);
+            if !path.exists() {
+                return helper_err!("qsv_loadjson", "\"{}\" does not exist.", path.display());
+            }
+
+            // Read the JSON file
+            let json_str = match fs::read_to_string(path) {
+                Ok(content) => content,
+                Err(e) => {
+                    return helper_err!(
+                        "qsv_loadjson",
+                        "Failed to read JSON file \"{}\": {e}",
+                        path.display()
+                    );
+                },
+            };
+
+            // Parse the JSON string
+            let json_value: serde_json::Value = match serde_json::from_str(&json_str) {
+                Ok(v) => v,
+                Err(e) => {
+                    return helper_err!(
+                        "qsv_loadjson",
+                        "Failed to parse JSON from \"{}\": {e}",
+                        path.display()
+                    );
+                },
+            };
+
+            // Convert JSON value to Luau value and store it in the global table
+            let luau_value = match luau.to_value(&json_value) {
+                Ok(v) => v,
+                Err(e) => {
+                    return helper_err!(
+                        "qsv_loadjson",
+                        "Failed to convert JSON to Luau value: {e}"
+                    );
+                },
+            };
+
+            luau.globals().raw_set(table_name.clone(), luau_value)?;
+
+            info!(
+                "{} successfully loaded JSON into table '{}'.",
+                filepath, table_name
+            );
+
+            Ok(true)
+        })?;
+    luau.globals().set("qsv_loadjson", qsv_loadjson)?;
+
     // this is a helper function that can be called from the BEGIN, MAIN & END scripts to write to
     // a file. The file will be created if it does not exist. The file will be appended to if it
     // already exists. The filename will be sanitized and will be written to the current working
@@ -2030,6 +2097,271 @@ fn setup_helpers(
     })?;
     luau.globals().set("qsv_shellcmd", qsv_shellcmd)?;
 
+    // this is a helper function that calculates the cumulative sum of a numeric column.
+    // if the input cannot be converted to a number, it returns 0 for that row.
+    //
+    //   qsv_cumsum(name, value)
+    //          name: identifier for this cumulative sum (allows multiple sums to run in parallel)
+    //         value: the numeric value to add to the cumulative sum
+    //       returns: the cumulative sum up to the current row for the named sum
+    //
+    let qsv_cumsum = luau.create_function(|_, (name, value): (String, mlua::Value)| {
+        // Get static cumulative sums using thread_local storage
+        thread_local! {
+            static CUMSUMS: RefCell<HashMap<String, f64>> = RefCell::new(HashMap::new());
+        }
+
+        // Convert input value to number, defaulting to 0.0 if conversion fails
+        let num = match value {
+            Value::Number(n) => n,
+            Value::Integer(i) => i as f64,
+            Value::String(s) => s.to_string_lossy().parse::<f64>().unwrap_or_default(),
+            _ => 0.0,
+        };
+
+        // Update cumulative sum for this name
+        CUMSUMS.with(|cs| {
+            let mut sums = cs.borrow_mut();
+            let sum = sums.entry(name).or_insert(0.0);
+            *sum += num;
+            Ok(*sum)
+        })
+    })?;
+    luau.globals().set("qsv_cumsum", qsv_cumsum)?;
+
+    // this is a helper function that calculates the cumulative product of a numeric column.
+    // if the input cannot be converted to a number, it returns 1 for that row.
+    //
+    //   qsv_cumprod(name, value)
+    //          name: identifier for this cumulative product (allows multiple products to run in
+    // parallel)         value: the numeric value to multiply with the cumulative product
+    //       returns: the cumulative product up to the current row for the named product
+    //
+    let qsv_cumprod = luau.create_function(|_, (name, value): (String, mlua::Value)| {
+        thread_local! {
+            static CUMPRODS: RefCell<HashMap<String, f64>> = RefCell::new(HashMap::new());
+        }
+
+        let num = match value {
+            Value::Number(n) => n,
+            Value::Integer(i) => i as f64,
+            Value::String(s) => s.to_string_lossy().parse::<f64>().unwrap_or(1.0),
+            _ => 1.0,
+        };
+
+        CUMPRODS.with(|cp| {
+            let mut prods = cp.borrow_mut();
+            let prod = prods.entry(name).or_insert(1.0);
+            *prod *= num;
+            Ok(*prod)
+        })
+    })?;
+    luau.globals().set("qsv_cumprod", qsv_cumprod)?;
+
+    // this is a helper function that calculates the cumulative maximum of a numeric column.
+    // if the input cannot be converted to a number, it returns negative infinity for that row.
+    //
+    //   qsv_cummax(name, value)
+    //          name: identifier for this cumulative maximum (allows multiple maximums to run in
+    // parallel)         value: the numeric value to compare with the cumulative maximum
+    //       returns: the cumulative maximum up to the current row for the named maximum
+    //
+    let qsv_cummax = luau.create_function(|_, (name, value): (String, mlua::Value)| {
+        thread_local! {
+            static CUMMAXS: RefCell<HashMap<String, f64>> = RefCell::new(HashMap::new());
+        }
+
+        let num = match value {
+            Value::Number(n) => n,
+            Value::Integer(i) => i as f64,
+            Value::String(s) => s
+                .to_string_lossy()
+                .parse::<f64>()
+                .unwrap_or(f64::NEG_INFINITY),
+            _ => f64::NEG_INFINITY,
+        };
+
+        CUMMAXS.with(|cm| {
+            let mut maxs = cm.borrow_mut();
+            let max = maxs.entry(name).or_insert(f64::NEG_INFINITY);
+            *max = max.max(num);
+            Ok(*max)
+        })
+    })?;
+    luau.globals().set("qsv_cummax", qsv_cummax)?;
+
+    // this is a helper function that calculates the cumulative minimum of a numeric column.
+    // if the input cannot be converted to a number, it returns positive infinity for that row.
+    //
+    //   qsv_cummin(name, value)
+    //          name: identifier for this cumulative minimum (allows multiple minimums to run in
+    // parallel)         value: the numeric value to compare with the cumulative minimum
+    //       returns: the cumulative minimum up to the current row for the named minimum
+    //
+    let qsv_cummin = luau.create_function(|_, (name, value): (String, mlua::Value)| {
+        thread_local! {
+            static CUMMINS: RefCell<HashMap<String, f64>> = RefCell::new(HashMap::new());
+        }
+
+        let num = match value {
+            Value::Number(n) => n,
+            Value::Integer(i) => i as f64,
+            Value::String(s) => s.to_string_lossy().parse::<f64>().unwrap_or(f64::INFINITY),
+            _ => f64::INFINITY,
+        };
+
+        CUMMINS.with(|cm| {
+            let mut mins = cm.borrow_mut();
+            let min = mins.entry(name).or_insert(f64::INFINITY);
+            *min = min.min(num);
+            Ok(*min)
+        })
+    })?;
+    luau.globals().set("qsv_cummin", qsv_cummin)?;
+
+    // qsv_lag - returns lagged value with optional default
+    //
+    //   qsv_lag(name, value, lag, default)
+    //          name: identifier for this lag (allows multiple lags to run in parallel)
+    //         value: the value to lag
+    //           lag: (optional) number of rows to lag by (default: 1)
+    //       default: (optional) value to return for rows before lag is available (default: "0")
+    //       returns: the value from 'lag' rows ago, or default if not enough rows seen yet
+    let qsv_lag = luau.create_function(|luau, (name, value, lag, default): (String, mlua::Value, Option<i64>, Option<mlua::Value>)| {
+        thread_local! {
+            static LAGS: RefCell<HashMap<String, Vec<String>>> = RefCell::new(HashMap::new());
+        }
+
+        let lag = lag.unwrap_or(1);
+        let key = format!("{name}_{lag}");
+
+        // Convert the value to a string to store it
+        let value_str = match &value {
+            Value::String(s) => s.to_string_lossy(),
+            Value::Number(n) => n.to_string(),
+            Value::Integer(i) => i.to_string(),
+            Value::Boolean(b) => b.to_string(),
+            Value::Nil => String::new(),
+            _ => value.to_string().unwrap_or_default(),
+        };
+
+        LAGS.with(|l| {
+            let mut lags = l.borrow_mut();
+            let values = lags.entry(key).or_default();
+            values.push(value_str);
+
+            if values.len() as i64 <= lag {
+                // Return the default value when not enough history
+                Ok(default.unwrap_or_else(|| {
+                    mlua::Value::String(luau.create_string("0").unwrap())
+                }))
+            } else {
+                let lagged_value = &values[values.len() - 1 - lag as usize];
+                Ok(mlua::Value::String(luau.create_string(lagged_value)?))
+            }
+        })
+    })?;
+    luau.globals().set("qsv_lag", qsv_lag)?;
+
+    // qsv_cumany - returns true if any value so far has been truthy
+    //
+    //   qsv_cumany(name, value)
+    //          name: identifier for this cumulative any (allows multiple cumany's to run in
+    // parallel)         value: the value to check for truthiness
+    //       returns: true if any value seen so far has been truthy, false otherwise
+    let qsv_cumany = luau.create_function(|_, (name, value): (String, mlua::Value)| {
+        thread_local! {
+            static CUMANYS: RefCell<HashMap<String, bool>> = RefCell::new(HashMap::new());
+        }
+
+        let is_truthy = match value {
+            Value::Boolean(b) => b,
+            Value::Number(n) => n != 0.0,
+            Value::Integer(i) => i != 0,
+            Value::String(s) => !s.to_string_lossy().is_empty(),
+            Value::Nil => false,
+            _ => true, // Tables, functions, etc. are considered truthy
+        };
+
+        CUMANYS.with(|ca| {
+            let mut anys = ca.borrow_mut();
+            let any = anys.entry(name).or_insert(false);
+            *any = *any || is_truthy;
+            Ok(*any)
+        })
+    })?;
+    luau.globals().set("qsv_cumany", qsv_cumany)?;
+
+    // qsv_cumall - returns true if all values so far have been truthy
+    //
+    //   qsv_cumall(name, value)
+    //          name: identifier for this cumulative all (allows multiple cumall's to run in
+    // parallel)         value: the value to check for truthiness
+    //       returns: true if all values seen so far have been truthy, false otherwise
+    let qsv_cumall = luau.create_function(|_, (name, value): (String, mlua::Value)| {
+        thread_local! {
+            static CUMALLS: RefCell<HashMap<String, bool>> = RefCell::new(HashMap::new());
+        }
+
+        let is_truthy = match value {
+            Value::Boolean(b) => b,
+            Value::Number(n) => n != 0.0,
+            Value::Integer(i) => i != 0,
+            Value::String(s) => !s.to_string_lossy().is_empty(),
+            Value::Nil => false,
+            _ => true, // Tables, functions, etc. are considered truthy
+        };
+
+        CUMALLS.with(|ca| {
+            let mut all_vals = ca.borrow_mut();
+            let all = all_vals.entry(name).or_insert(true);
+            *all = *all && is_truthy;
+            Ok(*all)
+        })
+    })?;
+    luau.globals().set("qsv_cumall", qsv_cumall)?;
+
+    // qsv_diff - returns difference between current and previous value
+    //
+    //   qsv_diff(name, value[, periods])
+    //          name: identifier for this diff (allows multiple diffs to run in parallel)
+    //         value: the value to calculate difference for
+    //       periods: optional number of periods to look back (default: 1)
+    //       returns: difference between current value and value 'periods' rows back
+    //               returns 0 if not enough history available yet
+    let qsv_diff = luau.create_function(
+        |_, (name, value, periods): (String, mlua::Value, Option<i64>)| {
+            thread_local! {
+                static DIFFS: RefCell<HashMap<String, Vec<f64>>> = RefCell::new(HashMap::new());
+            }
+
+            let periods = periods.unwrap_or(1);
+            // Create a unique key that includes both the name and periods
+            let key = format!("{name}_{periods}");
+
+            let num = match value {
+                Value::Number(n) => n,
+                Value::Integer(i) => i as f64,
+                Value::String(s) => s.to_string_lossy().parse::<f64>().unwrap_or(0.0),
+                _ => 0.0,
+            };
+
+            DIFFS.with(|d| {
+                let mut diffs = d.borrow_mut();
+                let values = diffs.entry(key).or_default();
+                values.push(num);
+
+                if values.len() as i64 <= periods {
+                    Ok(0.0) // Return 0 when not enough history
+                } else {
+                    let prev_value = values[values.len() - 1 - periods as usize];
+                    Ok(num - prev_value)
+                }
+            })
+        },
+    )?;
+    luau.globals().set("qsv_diff", qsv_diff)?;
+
     // this is a helper function that can be called from the BEGIN script to register
     // and load a lookup table. It expects two arguments - the lookup_name & the
     // lookup_table_uri - the URI of the CSV to use as a lookup table.


Test patch:
diff --git a/tests/test_luau.rs b/tests/test_luau.rs
index a926845ac..599d065c2 100644
--- a/tests/test_luau.rs
+++ b/tests/test_luau.rs
@@ -2313,3 +2313,621 @@ return tonumber(number) > tonumber(limit)
     ];
     assert_eq!(got, expected);
 }
+
+#[test]
+fn luau_loadjson() {
+    let wrk = Workdir::new("luau_loadjson");
+
+    // Create a test JSON file
+    wrk.create_from_string(
+        "test.json",
+        r#"{
+            "string": "hello",
+            "number": 42,
+            "boolean": true,
+            "null": null,
+            "array": [1, 2, 3],
+            "object": {
+                "nested": "value",
+                "numbers": [4, 5, 6]
+            }
+        }"#,
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("result")
+        .arg(r#"
+BEGIN {
+    qsv_loadjson("config_tbl", "test.json");
+    qsv_log("debug", "Loaded JSON table:", config_tbl)
+    for k,v in pairs(config_tbl) do
+        qsv_log("debug", "Key:", k, "Value:", v)
+    end
+}!
+
+return config_tbl["string"] .. " " .. config_tbl["object"]["nested"] .. " " .. tostring(config_tbl["number"])
+"#)
+        .arg("--no-headers")
+        .arg("data.csv");
+
+    // Create a simple input CSV
+    wrk.create("data.csv", vec![svec!["a"], svec!["b"], svec!["c"]]);
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["a", "hello value 42"],
+        svec!["b", "hello value 42"],
+        svec!["c", "hello value 42"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_loadjson_array() {
+    let wrk = Workdir::new("luau_loadjson_array");
+
+    // Create a test JSON file with an array
+    wrk.create_from_string(
+        "test.json",
+        r#"[
+            {"id": 1, "name": "Alice"},
+            {"id": 2, "name": "Bob"},
+            {"id": 3, "name": "Charlie"}
+        ]"#,
+    );
+
+    // Create input CSV with index references
+    wrk.create(
+        "data.csv",
+        vec![svec!["x"], svec!["1"], svec!["2"], svec!["3"]],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("name")
+        .arg(
+            r#"
+BEGIN {
+    qsv_loadjson("users", "test.json");
+}!
+
+local idx = tonumber(x)
+return users[idx]["name"]
+"#,
+        )
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["x", "name"],
+        svec!["1", "Alice"],
+        svec!["2", "Bob"],
+        svec!["3", "Charlie"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_loadjson_error_missing_file() {
+    let wrk = Workdir::new("luau_loadjson_error");
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("result")
+        .arg(
+            r#"
+BEGIN {
+    qsv_loadjson("config", "nonexistent.json");
+}!
+
+return "should not get here"
+"#,
+        )
+        .arg("--no-headers")
+        .arg("data.csv");
+
+    // Create a simple input CSV
+    wrk.create("data.csv", vec![svec!["a"]]);
+
+    wrk.assert_err(&mut cmd);
+}
+
+#[test]
+fn luau_loadjson_error_invalid_json() {
+    let wrk = Workdir::new("luau_loadjson_invalid");
+
+    // Create an invalid JSON file
+    wrk.create_from_string(
+        "invalid.json",
+        r#"{
+            "unclosed": "object"
+        "#, // Missing closing brace
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("result")
+        .arg(
+            r#"
+BEGIN {
+    qsv_loadjson("config", "invalid.json");
+}!
+
+return "should not get here"
+"#,
+        )
+        .arg("--no-headers")
+        .arg("data.csv");
+
+    // Create a simple input CSV
+    wrk.create("data.csv", vec![svec!["a"]]);
+
+    wrk.assert_err(&mut cmd);
+}
+
+#[test]
+fn luau_cumsum_single() {
+    let wrk = Workdir::new("luau_cumsum_single");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["amount"],
+            svec!["10"],
+            svec!["20"],
+            svec!["30"],
+            svec!["40"],
+            svec!["50"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("running_total")
+        .arg(r#"qsv_cumsum("total", amount)"#)
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["amount", "running_total"],
+        svec!["10", "10"],
+        svec!["20", "30"],
+        svec!["30", "60"],
+        svec!["40", "100"],
+        svec!["50", "150"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_cumsum_multiple() {
+    let wrk = Workdir::new("luau_cumsum_multiple");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["revenue", "expenses"],
+            svec!["100", "50"],
+            svec!["200", "75"],
+            svec!["150", "80"],
+            svec!["300", "100"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("revenue_total,expenses_total,profit_total")
+        .arg(
+            r#"{
+            qsv_cumsum("revenue", revenue),
+            qsv_cumsum("expenses", expenses),
+            qsv_cumsum("profit", tonumber(revenue) - tonumber(expenses))
+        }"#,
+        )
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec![
+            "revenue",
+            "expenses",
+            "revenue_total",
+            "expenses_total",
+            "profit_total"
+        ],
+        svec!["100", "50", "100", "50", "50"],
+        svec!["200", "75", "300", "125", "175"],
+        svec!["150", "80", "450", "205", "245"],
+        svec!["300", "100", "750", "305", "445"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_cumsum_invalid_input() {
+    let wrk = Workdir::new("luau_cumsum_invalid");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["10"],
+            svec!["invalid"],
+            svec!["20"],
+            svec!["bad"],
+            svec!["30"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("total")
+        .arg(r#"qsv_cumsum("sum", value)"#)
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "total"],
+        svec!["10", "10"],
+        svec!["invalid", "10"], // Invalid input treated as 0
+        svec!["20", "30"],
+        svec!["bad", "30"], // Invalid input treated as 0
+        svec!["30", "60"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_cumprod() {
+    let wrk = Workdir::new("luau_cumprod");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["2"],
+            svec!["3"],
+            svec!["4"],
+            svec!["5"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("running_product")
+        .arg(r#"qsv_cumprod("prod", value)"#)
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "running_product"],
+        svec!["2", "2"],
+        svec!["3", "6"],
+        svec!["4", "24"],
+        svec!["5", "120"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_cummax_cummin() {
+    let wrk = Workdir::new("luau_cummax_cummin");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["10"],
+            svec!["5"],
+            svec!["15"],
+            svec!["3"],
+            svec!["12"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("running_max,running_min")
+        .arg(r#"{qsv_cummax("max", value), qsv_cummin("min", value)}"#)
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "running_max", "running_min"],
+        svec!["10", "10", "10"],
+        svec!["5", "10", "5"],
+        svec!["15", "15", "5"],
+        svec!["3", "15", "3"],
+        svec!["12", "15", "3"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_lag() {
+    let wrk = Workdir::new("luau_lag");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["10"],
+            svec!["20"],
+            svec!["30"],
+            svec!["40"],
+            svec!["50"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("lag1,lag2")
+        .arg(
+            r#"{
+            qsv_lag("val", value, 1, "0"),
+            qsv_lag("val", value, 2, "0")
+        }"#,
+        )
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "lag1", "lag2"],
+        svec!["10", "0", "0"],
+        svec!["20", "10", "0"],
+        svec!["30", "20", "10"],
+        svec!["40", "30", "20"],
+        svec!["50", "40", "30"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_cumany_cumall() {
+    let wrk = Workdir::new("luau_cumany_cumall");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["10"],
+            svec!["0"],
+            svec!["15"],
+            svec!["0"],
+            svec!["20"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("has_zero,all_positive")
+        .arg(
+            r#"{
+            qsv_cumany("zero", value == "0"),
+            qsv_cumall("pos", tonumber(value) > 0)
+        }"#,
+        )
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "has_zero", "all_positive"],
+        svec!["10", "false", "true"],
+        svec!["0", "true", "false"],
+        svec!["15", "true", "false"],
+        svec!["0", "true", "false"],
+        svec!["20", "true", "false"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_diff() {
+    let wrk = Workdir::new("luau_diff");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["10"],
+            svec!["15"],
+            svec!["13"],
+            svec!["20"],
+            svec!["18"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("diff1,diff2")
+        .arg(
+            r#"{
+            qsv_diff("val", value),
+            qsv_diff("val", value, 2)
+        }"#,
+        )
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "diff1", "diff2"],
+        svec!["10", "0", "0"],
+        svec!["15", "5", "0"],
+        svec!["13", "-2", "3"],
+        svec!["20", "7", "5"],
+        svec!["18", "-2", "5"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_cumany_cumall_with_strings() {
+    let wrk = Workdir::new("luau_cumany_cumall_strings");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["hello"],
+            svec![""],
+            svec!["world"],
+            svec![""],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("has_empty,all_nonempty")
+        .arg(
+            r#"{
+            qsv_cumany("empty", value == ""),
+            qsv_cumall("nonempty", value ~= "")
+        }"#,
+        )
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "has_empty", "all_nonempty"],
+        svec!["hello", "false", "true"],
+        svec!["", "true", "false"],
+        svec!["world", "true", "false"],
+        svec!["", "true", "false"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_cumall_with_numbers() {
+    let wrk = Workdir::new("luau_cumall_numbers");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["10"],
+            svec!["0"],
+            svec!["20"],
+            svec!["30"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("all_positive")
+        .arg(r#"qsv_cumall("pos", tonumber(value) > 0)"#)
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "all_positive"],
+        svec!["10", "true"],
+        svec!["0", "false"],
+        svec!["20", "false"],
+        svec!["30", "false"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_lag_with_default() {
+    let wrk = Workdir::new("luau_lag_default");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["10"],
+            svec!["20"],
+            svec!["30"],
+            svec!["40"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("prev,prev2")
+        .arg(
+            r#"{
+            qsv_lag("lag1", value, 1, "N/A"),
+            qsv_lag("lag2", value, 2, "N/A")
+        }"#,
+        )
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "prev", "prev2"],
+        svec!["10", "N/A", "N/A"],
+        svec!["20", "10", "N/A"],
+        svec!["30", "20", "10"],
+        svec!["40", "30", "20"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_diff_with_lag() {
+    let wrk = Workdir::new("luau_diff_lag");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["100"],
+            svec!["120"],
+            svec!["115"],
+            svec!["140"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("diff1,diff2,pct_change")
+        .arg(
+            r#"{
+            qsv_diff("d1", value),
+            qsv_diff("d2", value, 2),
+            string.format("%.1f%%", ((tonumber(value) / tonumber(qsv_lag("pct", value))) - 1.0) * 100)
+        }"#,
+        )
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "diff1", "diff2", "pct_change"],
+        svec!["100", "0", "0", "inf%"], // division by zero
+        svec!["120", "20", "0", "20.0%"],
+        svec!["115", "-5", "15", "-4.2%"],
+        svec!["140", "25", "20", "21.7%"],
+    ];
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn luau_cumulative_stats() {
+    let wrk = Workdir::new("luau_cumulative_stats");
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["value"],
+            svec!["15"],
+            svec!["10"],
+            svec!["25"],
+            svec!["5"],
+            svec!["20"],
+        ],
+    );
+
+    let mut cmd = wrk.command("luau");
+    cmd.arg("map")
+        .arg("running_max,running_min,running_sum")
+        .arg(
+            r#"{
+            qsv_cummax("max", value),
+            qsv_cummin("min", value),
+            qsv_cumsum("sum", value)
+        }"#,
+        )
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["value", "running_max", "running_min", "running_sum"],
+        svec!["15", "15", "15", "15"],
+        svec!["10", "15", "10", "25"],
+        svec!["25", "25", "10", "50"],
+        svec!["5", "25", "5", "55"],
+        svec!["20", "25", "5", "75"],
+    ];
+    assert_eq!(got, expected);
+}