use async_trait::async_trait;
use jaq_core::load::{Arena, File, Loader};
use jaq_core::{Compiler, Ctx, Vars, data};
use jaq_json::Val;
use jaq_std::input::{HasInputs, Inputs, RcIter};
use super::{Builtin, Context, read_text_file, resolve_path};
use crate::error::{Error, Result};
use crate::interpreter::ExecResult;
struct InputData<V>(std::marker::PhantomData<V>);
impl<V: jaq_core::ValT + 'static> data::DataT for InputData<V> {
type V<'a> = V;
type Data<'a> = InputDataRef<'a, V>;
}
#[derive(Clone)]
struct InputDataRef<'a, V: jaq_core::ValT + 'static> {
lut: &'a jaq_core::Lut<InputData<V>>,
inputs: &'a RcIter<dyn Iterator<Item = std::result::Result<V, String>> + 'a>,
}
impl<'a, V: jaq_core::ValT + 'static> data::HasLut<'a, InputData<V>> for InputDataRef<'a, V> {
fn lut(&self) -> &'a jaq_core::Lut<InputData<V>> {
self.lut
}
}
impl<'a, V: jaq_core::ValT + 'static> HasInputs<'a, V> for InputDataRef<'a, V> {
fn inputs(&self) -> Inputs<'a, V> {
self.inputs
}
}
fn serde_to_val(v: serde_json::Value) -> Val {
match v {
serde_json::Value::Null => Val::Null,
serde_json::Value::Bool(b) => Val::from(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
if let Ok(i) = isize::try_from(i) {
Val::from(i)
} else {
Val::from(i as f64)
}
} else if let Some(f) = n.as_f64() {
Val::from(f)
} else {
Val::from(0isize) }
}
serde_json::Value::String(s) => Val::from(s),
serde_json::Value::Array(arr) => arr.into_iter().map(serde_to_val).collect(),
serde_json::Value::Object(map) => Val::obj(
map.into_iter()
.map(|(k, v)| (Val::from(k), serde_to_val(v)))
.collect(),
),
}
}
fn val_to_serde(v: &Val) -> serde_json::Value {
match v {
Val::Null => serde_json::Value::Null,
Val::Bool(b) => serde_json::Value::Bool(*b),
Val::Num(n) => {
let s = format!("{n}");
if let Ok(i) = s.parse::<i64>() {
serde_json::Value::Number(serde_json::Number::from(i))
} else if let Ok(f) = s.parse::<f64>() {
serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)
} else {
serde_json::Value::Null
}
}
Val::BStr(_) | Val::TStr(_) => {
let displayed = format!("{v}");
if displayed.starts_with('"') && displayed.ends_with('"') {
serde_json::from_str(&displayed).unwrap_or(serde_json::Value::String(displayed))
} else {
serde_json::Value::String(displayed)
}
}
Val::Arr(a) => serde_json::Value::Array(a.iter().map(val_to_serde).collect()),
Val::Obj(o) => {
let map: serde_json::Map<String, serde_json::Value> = o
.iter()
.map(|(k, v)| {
let key = match k {
Val::TStr(_) | Val::BStr(_) => {
let s = format!("{k}");
if s.starts_with('"') && s.ends_with('"') {
serde_json::from_str::<String>(&s).unwrap_or(s)
} else {
s
}
}
_ => format!("{k}"),
};
(key, val_to_serde(v))
})
.collect();
serde_json::Value::Object(map)
}
}
}
const MAX_JQ_JSON_DEPTH: usize = 100;
const JQ_COMPAT_DEFS: &str = r#"
def setpath(p; v):
if (p | length) == 0 then v
else p[0] as $k |
(if . == null then
if ($k | type) == "number" then [] else {} end
else . end) |
.[$k] |= setpath(p[1:]; v)
end;
def leaf_paths: paths(scalars);
def match(re; flags):
matches(re; flags)[] |
.[0] as $m |
{ offset: $m.offset, length: $m.length, string: $m.string,
captures: [.[1:][] | { offset: .offset, length: .length, string: .string,
name: (if has("name") then .name else null end) }] };
def match(re): match(re; "");
def scan(re; flags): matches(re; "g" + flags)[] | .[0].string;
def scan(re): scan(re; "");
"#;
const ENV_VAR_NAME: &str = "$__bashkit_env__";
pub struct Jq;
impl Jq {
fn parse_json_values(input: &str) -> Result<Vec<serde_json::Value>> {
use serde_json::Deserializer;
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let mut vals = Vec::new();
let stream = Deserializer::from_str(trimmed).into_iter::<serde_json::Value>();
for result in stream {
let json_input =
result.map_err(|e| Error::Execution(format!("jq: invalid JSON: {}", e)))?;
check_json_depth(&json_input, MAX_JQ_JSON_DEPTH).map_err(Error::Execution)?;
vals.push(json_input);
}
Ok(vals)
}
}
fn check_json_depth(
value: &serde_json::Value,
max_depth: usize,
) -> std::result::Result<(), String> {
fn measure_depth(
v: &serde_json::Value,
current: usize,
max: usize,
) -> std::result::Result<(), String> {
if current > max {
return Err(format!(
"jq: JSON nesting too deep ({} levels, max {})",
current, max
));
}
match v {
serde_json::Value::Array(arr) => {
for item in arr {
measure_depth(item, current + 1, max)?;
}
}
serde_json::Value::Object(map) => {
for (_k, item) in map {
measure_depth(item, current + 1, max)?;
}
}
_ => {}
}
Ok(())
}
measure_depth(value, 0, max_depth)
}
fn sort_json_keys(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted: Vec<(String, serde_json::Value)> = map
.into_iter()
.map(|(k, v)| (k, sort_json_keys(v)))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
serde_json::Value::Object(sorted.into_iter().collect())
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(sort_json_keys).collect())
}
other => other,
}
}
fn format_with_tabs(value: &serde_json::Value) -> String {
let pretty = serde_json::to_string_pretty(value).unwrap_or_default();
let mut result = String::new();
for line in pretty.lines() {
let spaces = line.len() - line.trim_start().len();
let tabs = spaces / 2;
result.push_str(&"\t".repeat(tabs));
result.push_str(line.trim_start());
result.push('\n');
}
result.truncate(result.trim_end_matches('\n').len());
result
}
#[async_trait]
impl Builtin for Jq {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
for arg in ctx.args {
if arg == "-V" || arg == "--version" {
return Ok(ExecResult::ok("jq-1.8\n".to_string()));
}
}
let mut raw_output = false;
let mut raw_input = false;
let mut compact_output = false;
let mut null_input = false;
let mut sort_keys = false;
let mut slurp = false;
let mut exit_status = false;
let mut tab_indent = false;
let mut join_output = false;
let mut filter = ".";
let mut file_args: Vec<&str> = Vec::new();
let mut var_bindings: Vec<(String, serde_json::Value)> = Vec::new();
let mut found_filter = false;
let mut i = 0;
while i < ctx.args.len() {
let arg = &ctx.args[i];
if found_filter {
file_args.push(arg);
i += 1;
continue;
}
if arg == "--" {
i += 1;
if i < ctx.args.len() {
filter = &ctx.args[i];
found_filter = true;
}
i += 1;
continue;
}
if arg == "--raw-output" {
raw_output = true;
} else if arg == "--raw-input" {
raw_input = true;
} else if arg == "--compact-output" {
compact_output = true;
} else if arg == "--null-input" {
null_input = true;
} else if arg == "--sort-keys" {
sort_keys = true;
} else if arg == "--slurp" {
slurp = true;
} else if arg == "--exit-status" {
exit_status = true;
} else if arg == "--tab" {
tab_indent = true;
} else if arg == "--join-output" {
join_output = true;
} else if arg == "--arg" {
if i + 2 < ctx.args.len() {
let name = format!("${}", &ctx.args[i + 1]);
let value = serde_json::Value::String(ctx.args[i + 2].to_string());
var_bindings.push((name, value));
i += 3;
continue;
}
i += 1;
continue;
} else if arg == "--argjson" {
if i + 2 < ctx.args.len() {
let name = format!("${}", &ctx.args[i + 1]);
let json_val: serde_json::Value = match serde_json::from_str(&ctx.args[i + 2]) {
Ok(v) => v,
Err(e) => {
return Ok(ExecResult::err(
format!("jq: invalid JSON for --argjson: {}\n", e),
2,
));
}
};
var_bindings.push((name, json_val));
i += 3;
continue;
}
i += 1;
continue;
} else if arg == "--indent" {
i += 2;
continue;
} else if arg == "--args" || arg == "--jsonargs" {
i += 1;
continue;
} else if arg.starts_with("--") {
} else if arg.starts_with('-') && arg.len() > 1 {
for ch in arg[1..].chars() {
match ch {
'r' => raw_output = true,
'R' => raw_input = true,
'c' => compact_output = true,
'n' => null_input = true,
'S' => sort_keys = true,
's' => slurp = true,
'e' => exit_status = true,
'j' => join_output = true,
_ => {} }
}
} else {
filter = arg;
found_filter = true;
}
i += 1;
}
let file_content: String;
let input = if !file_args.is_empty() {
let mut combined = String::new();
for file_arg in &file_args {
let path = resolve_path(ctx.cwd, file_arg);
let text = match read_text_file(&*ctx.fs, &path, "jq").await {
Ok(t) => t,
Err(e) => return Ok(e),
};
if !combined.is_empty() && !combined.ends_with('\n') {
combined.push('\n');
}
combined.push_str(&text);
}
file_content = combined;
file_content.as_str()
} else {
ctx.stdin.unwrap_or("")
};
if input.trim().is_empty() && !null_input {
return Ok(ExecResult::ok(String::new()));
}
let defs = jaq_core::defs()
.chain(jaq_std::defs())
.chain(jaq_json::defs());
let loader = Loader::new(defs);
let arena = Arena::default();
let env_obj = {
let mut map = serde_json::Map::new();
for (k, v) in ctx.variables.iter().chain(ctx.env.iter()) {
map.insert(k.clone(), serde_json::Value::String(v.clone()));
}
serde_json::Value::Object(map)
};
let env_def = format!("def env: {};", ENV_VAR_NAME);
let compat_filter = format!("{}\n{}\n{}", JQ_COMPAT_DEFS, env_def, filter);
let filter = compat_filter.as_str();
let program = File {
code: filter,
path: (),
};
let modules = match loader.load(&arena, program) {
Ok(m) => m,
Err(errs) => {
let msg = format!(
"jq: parse error: {}\n",
errs.into_iter()
.map(|e| format!("{:?}", e))
.collect::<Vec<_>>()
.join(", ")
);
return Ok(ExecResult::err(msg, 3));
}
};
let mut var_names: Vec<&str> = var_bindings.iter().map(|(n, _)| n.as_str()).collect();
var_names.push(ENV_VAR_NAME);
type D = InputData<Val>;
let input_funs: Vec<jaq_core::native::Fun<D>> = jaq_std::input::funs::<D>()
.into_vec()
.into_iter()
.map(|(name, arity, run)| (name, arity, jaq_core::Native::<D>::new(run)))
.collect();
let native_funs = jaq_core::funs::<D>()
.chain(jaq_std::funs::<D>().filter(|(name, _, _)| *name != "env"))
.chain(input_funs)
.chain(jaq_json::funs::<D>());
let compiler = Compiler::default()
.with_funs(native_funs)
.with_global_vars(var_names.iter().copied());
let filter = match compiler.compile(modules) {
Ok(f) => f,
Err(errs) => {
let msg = format!(
"jq: compile error: {}\n",
errs.into_iter()
.map(|e| format!("{:?}", e))
.collect::<Vec<_>>()
.join(", ")
);
return Ok(ExecResult::err(msg, 3));
}
};
let mut output = String::new();
let inputs_to_process: Vec<Val> = if null_input {
vec![Val::Null]
} else if raw_input && slurp {
vec![Val::from(input.to_string())]
} else if raw_input {
input
.lines()
.map(|line| Val::from(line.to_string()))
.collect()
} else if slurp {
match Self::parse_json_values(input) {
Ok(vals) => vec![serde_to_val(serde_json::Value::Array(vals))],
Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 5)),
}
} else {
match Self::parse_json_values(input) {
Ok(json_vals) => json_vals.into_iter().map(serde_to_val).collect(),
Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 5)),
}
};
let mut has_output = false;
let mut all_null_or_false = true;
let iter: Box<dyn Iterator<Item = std::result::Result<Val, String>>> =
Box::new(inputs_to_process.into_iter().map(Ok::<Val, String>));
let shared_inputs = RcIter::new(iter);
let env_val = serde_to_val(env_obj);
for jaq_input in &shared_inputs {
let jaq_input: Val = match jaq_input {
Ok(v) => v,
Err(e) => {
return Ok(ExecResult::err(format!("jq: input error: {}\n", e), 5));
}
};
let mut var_vals: Vec<Val> = var_bindings
.iter()
.map(|(_, v)| serde_to_val(v.clone()))
.collect();
var_vals.push(env_val.clone());
let data = InputDataRef {
lut: &filter.lut,
inputs: &shared_inputs,
};
let ctx = Ctx::<InputData<Val>>::new(data, Vars::new(var_vals));
for result in filter.id.run((ctx, jaq_input)) {
match result {
Ok(val) => {
has_output = true;
let json = val_to_serde(&val);
if !matches!(
json,
serde_json::Value::Null | serde_json::Value::Bool(false)
) {
all_null_or_false = false;
}
let json = if sort_keys {
sort_json_keys(json)
} else {
json
};
let effective_raw = raw_output || join_output;
if effective_raw {
if let serde_json::Value::String(s) = &json {
output.push_str(s);
if !join_output {
output.push('\n');
}
} else {
let formatted = if compact_output {
serde_json::to_string(&json)
} else if tab_indent {
Ok(format_with_tabs(&json))
} else {
match &json {
serde_json::Value::Array(_)
| serde_json::Value::Object(_) => {
serde_json::to_string_pretty(&json)
}
_ => serde_json::to_string(&json),
}
};
match formatted {
Ok(s) => {
output.push_str(&s);
if !join_output {
output.push('\n');
}
}
Err(e) => {
return Ok(ExecResult::err(
format!("jq: output error: {}\n", e),
5,
));
}
}
}
} else if compact_output {
match serde_json::to_string(&json) {
Ok(s) => {
output.push_str(&s);
output.push('\n');
}
Err(e) => {
return Ok(ExecResult::err(
format!("jq: output error: {}\n", e),
5,
));
}
}
} else if tab_indent {
let formatted = format_with_tabs(&json);
output.push_str(&formatted);
output.push('\n');
} else {
let formatted = match &json {
serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
serde_json::to_string_pretty(&json)
}
_ => serde_json::to_string(&json),
};
match formatted {
Ok(s) => {
output.push_str(&s);
output.push('\n');
}
Err(e) => {
return Ok(ExecResult::err(
format!("jq: output error: {}\n", e),
5,
));
}
}
}
}
Err(e) => {
return Ok(ExecResult::err(format!("jq: runtime error: {:?}\n", e), 5));
}
}
}
}
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
if exit_status && (!has_output || all_null_or_false) {
return Ok(ExecResult::with_code(output, 1));
}
Ok(ExecResult::ok(output))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{FileSystem, InMemoryFs};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
async fn run_jq(filter: &str, input: &str) -> Result<String> {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args = vec![filter.to_string()];
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: Some(input),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
let result = jq.execute(ctx).await?;
Ok(result.stdout)
}
#[tokio::test]
async fn test_jq_identity() {
let result = run_jq(".", r#"{"name":"test"}"#).await.unwrap();
assert_eq!(result.trim(), "{\n \"name\": \"test\"\n}");
}
#[tokio::test]
async fn test_jq_field_access() {
let result = run_jq(".name", r#"{"name":"foo","id":42}"#).await.unwrap();
assert_eq!(result.trim(), r#""foo""#);
}
#[tokio::test]
async fn test_jq_array_index() {
let result = run_jq(".[1]", r#"["a","b","c"]"#).await.unwrap();
assert_eq!(result.trim(), r#""b""#);
}
#[tokio::test]
async fn test_jq_nested() {
let result = run_jq(".user.name", r#"{"user":{"name":"alice"}}"#)
.await
.unwrap();
assert_eq!(result.trim(), r#""alice""#);
}
#[tokio::test]
async fn test_jq_keys() {
let result = run_jq("keys", r#"{"b":1,"a":2}"#).await.unwrap();
assert_eq!(result.trim(), "[\n \"a\",\n \"b\"\n]");
}
#[tokio::test]
async fn test_jq_length() {
let result = run_jq("length", r#"[1,2,3,4,5]"#).await.unwrap();
assert_eq!(result.trim(), "5");
}
async fn run_jq_with_args(args: &[&str], input: &str) -> Result<String> {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: Some(input),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
let result = jq.execute(ctx).await?;
Ok(result.stdout)
}
async fn run_jq_result(filter: &str, input: &str) -> Result<ExecResult> {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args = vec![filter.to_string()];
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: Some(input),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
jq.execute(ctx).await
}
async fn run_jq_result_with_args(args: &[&str], input: &str) -> Result<ExecResult> {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: Some(input),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
jq.execute(ctx).await
}
#[tokio::test]
async fn test_jq_raw_output() {
let result = run_jq_with_args(&["-r", ".name"], r#"{"name":"test"}"#)
.await
.unwrap();
assert_eq!(result.trim(), "test");
}
#[tokio::test]
async fn test_jq_raw_output_long_flag() {
let result = run_jq_with_args(&["--raw-output", ".name"], r#"{"name":"test"}"#)
.await
.unwrap();
assert_eq!(result.trim(), "test");
}
#[tokio::test]
async fn test_jq_version() {
let result = run_jq_with_args(&["--version"], "").await.unwrap();
assert!(result.starts_with("jq-"));
}
#[tokio::test]
async fn test_jq_version_short() {
let result = run_jq_with_args(&["-V"], "").await.unwrap();
assert!(result.starts_with("jq-"));
}
#[tokio::test]
async fn test_jq_input_reads_next() {
let result = run_jq_with_args(&["input"], "1\n2").await.unwrap();
assert_eq!(result.trim(), "2");
}
#[tokio::test]
async fn test_jq_inputs_collects_remaining() {
let result = run_jq_with_args(&["-c", "[inputs]"], "1\n2\n3")
.await
.unwrap();
assert_eq!(result.trim(), "[2,3]");
}
#[tokio::test]
async fn test_jq_inputs_single_value() {
let result = run_jq_with_args(&["-c", "[inputs]"], "42").await.unwrap();
assert_eq!(result.trim(), "[]");
}
#[tokio::test]
async fn test_jq_json_depth_limit_arrays() {
let depth = 150;
let open = "[".repeat(depth);
let close = "]".repeat(depth);
let input = format!("{open}1{close}");
let result = run_jq_result(".", &input).await.unwrap();
assert!(
result.exit_code != 0,
"deeply nested JSON arrays must be rejected"
);
assert!(
result.stderr.contains("nesting too deep") || result.stderr.contains("recursion limit"),
"error should mention nesting or recursion limit: {}",
result.stderr
);
}
#[tokio::test]
async fn test_jq_json_depth_limit_objects() {
let depth = 150;
let mut input = String::from("1");
for _ in 0..depth {
input = format!(r#"{{"a":{input}}}"#);
}
let result = run_jq_result(".", &input).await.unwrap();
assert!(
result.exit_code != 0,
"deeply nested JSON objects must be rejected"
);
assert!(
result.stderr.contains("nesting too deep") || result.stderr.contains("recursion limit"),
"error should mention nesting or recursion limit: {}",
result.stderr
);
}
#[tokio::test]
async fn test_jq_moderate_nesting_ok() {
let depth = 10;
let open = "[".repeat(depth);
let close = "]".repeat(depth);
let input = format!("{open}1{close}");
let result = run_jq(".", &input).await;
assert!(
result.is_ok(),
"moderate nesting should succeed: {:?}",
result.err()
);
}
#[test]
fn test_check_json_depth() {
let v = serde_json::json!(42);
assert!(check_json_depth(&v, 100).is_ok());
let v = serde_json::json!([[[1]]]);
assert!(check_json_depth(&v, 5).is_ok());
assert!(check_json_depth(&v, 2).is_err());
}
async fn run_jq_with_files(
args: &[&str],
files: &[(&str, &str)],
) -> std::result::Result<ExecResult, Error> {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
for (path, content) in files {
let p = std::path::Path::new(path);
if let Some(parent) = p.parent()
&& parent != std::path::Path::new("/")
{
fs.mkdir(parent, true).await.unwrap();
}
fs.write_file(p, content.as_bytes()).await.unwrap();
}
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
jq.execute(ctx).await
}
#[tokio::test]
async fn test_jq_read_single_file() {
let result = run_jq_with_files(&[".", "/data.json"], &[("/data.json", r#"{"a":1}"#)])
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "{\n \"a\": 1\n}");
}
#[tokio::test]
async fn test_jq_read_multiple_files() {
let result = run_jq_with_files(
&[".", "/a.json", "/b.json"],
&[("/a.json", r#"{"x":1}"#), ("/b.json", r#"{"y":2}"#)],
)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
let lines: Vec<&str> = result.stdout.trim().split('\n').collect();
assert!(result.stdout.contains("\"x\": 1"), "should contain x:1");
assert!(result.stdout.contains("\"y\": 2"), "should contain y:2");
assert!(
lines.len() > 3,
"should have multi-line output for two objects"
);
}
#[tokio::test]
async fn test_jq_slurp_files() {
let result = run_jq_with_files(
&["-s", ".", "/a.json", "/b.json"],
&[("/a.json", r#"{"x":1}"#), ("/b.json", r#"{"y":2}"#)],
)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("\"x\": 1"), "should contain x:1");
assert!(result.stdout.contains("\"y\": 2"), "should contain y:2");
let parsed: serde_json::Value = serde_json::from_str(result.stdout.trim()).unwrap();
assert!(parsed.is_array(), "slurp output should be an array");
assert_eq!(parsed.as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn test_jq_file_not_found() {
let result = run_jq_with_files(&[".", "/missing.json"], &[])
.await
.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("jq: /missing.json:"));
}
#[tokio::test]
async fn test_jq_slurp_files_in_subdir() {
let result = run_jq_with_files(
&[
"-s",
".",
"/workspace/json_data/a.json",
"/workspace/json_data/b.json",
],
&[
("/workspace/json_data/a.json", r#"{"id":1}"#),
("/workspace/json_data/b.json", r#"{"id":2}"#),
],
)
.await
.unwrap();
assert_eq!(result.exit_code, 0);
let parsed: serde_json::Value = serde_json::from_str(result.stdout.trim()).unwrap();
assert!(parsed.is_array());
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["id"], 1);
assert_eq!(arr[1]["id"], 2);
}
#[tokio::test]
async fn test_jq_env_access() {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let mut env = HashMap::new();
env.insert("TESTVAR".to_string(), "hello".to_string());
let args = vec!["-n".to_string(), "env.TESTVAR".to_string()];
let ctx = Context {
args: &args,
env: &env,
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
let result = jq.execute(ctx).await.unwrap();
assert_eq!(result.stdout.trim(), "\"hello\"");
}
#[tokio::test]
async fn test_jq_env_missing_var() {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args = vec!["-n".to_string(), "env.NO_SUCH_VAR_999".to_string()];
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
let result = jq.execute(ctx).await.unwrap();
assert_eq!(result.stdout.trim(), "null");
}
#[tokio::test]
async fn test_jq_combined_short_flags() {
let result = run_jq_with_args(&["-rn", "1+1"], "").await.unwrap();
assert_eq!(result.trim(), "2");
}
#[tokio::test]
async fn test_jq_combined_flags_sc() {
let result = run_jq_with_args(&["-sc", "add"], "1\n2\n3\n")
.await
.unwrap();
assert_eq!(result.trim(), "6");
}
#[tokio::test]
async fn test_jq_arg_flag() {
let result = run_jq_with_args(&["--arg", "name", "world", "-n", r#""hello \($name)""#], "")
.await
.unwrap();
assert_eq!(result.trim(), r#""hello world""#);
}
#[tokio::test]
async fn test_jq_argjson_flag() {
let result = run_jq_with_args(&["--argjson", "count", "5", "-n", "$count + 1"], "")
.await
.unwrap();
assert_eq!(result.trim(), "6");
}
#[tokio::test]
async fn test_jq_arg_does_not_eat_filter() {
let result = run_jq_with_args(&["--arg", "x", "hello", "."], r#"{"a":1}"#)
.await
.unwrap();
assert!(result.contains("\"a\": 1"));
}
#[tokio::test]
async fn test_jq_double_dash_separator() {
let result = run_jq_with_args(&["-n", "--", "1+1"], "").await.unwrap();
assert_eq!(result.trim(), "2");
}
#[tokio::test]
async fn test_jq_indent_flag() {
let result = run_jq_with_args(&["--indent", "4", "."], r#"{"a":1}"#)
.await
.unwrap();
assert!(result.contains("\"a\""));
}
#[tokio::test]
async fn test_jq_invalid_json_input() {
let result = run_jq_result(".", "not valid json").await.unwrap();
assert!(
result.exit_code != 0,
"invalid JSON input should have non-zero exit"
);
assert!(
result.stderr.contains("jq:"),
"should have jq error in stderr"
);
}
#[tokio::test]
async fn test_jq_invalid_filter_syntax() {
let result = run_jq_result(".[", r#"{"a":1}"#).await.unwrap();
assert!(
result.exit_code != 0,
"invalid filter should have non-zero exit"
);
assert!(
result.stderr.contains("jq:"),
"should have jq error in stderr"
);
}
#[tokio::test]
async fn test_jq_invalid_argjson_value() {
let result = run_jq_result_with_args(&["--argjson", "x", "not json", "-n", "$x"], "")
.await
.unwrap();
assert!(
result.exit_code != 0,
"invalid JSON for --argjson should have non-zero exit"
);
}
#[tokio::test]
async fn test_jq_empty_input_no_null() {
let result = run_jq(".", "").await.unwrap();
assert_eq!(result, "");
}
#[tokio::test]
async fn test_jq_whitespace_only_input() {
let result = run_jq(".", " \n\t ").await.unwrap();
assert_eq!(result, "");
}
#[tokio::test]
async fn test_jq_ndjson_multiple_values() {
let result = run_jq(".a", "{\"a\":1}\n{\"a\":2}\n").await.unwrap();
assert_eq!(result.trim(), "1\n2");
}
#[tokio::test]
async fn test_jq_exit_status_false() {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args = vec!["-e".to_string(), ".".to_string()];
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: Some("false"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
let result = jq.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1, "-e with false should exit 1");
}
#[tokio::test]
async fn test_jq_exit_status_truthy() {
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args = vec!["-e".to_string(), ".".to_string()];
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: Some("42"),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
let result = jq.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 0, "-e with truthy value should exit 0");
}
#[tokio::test]
async fn test_jq_multiple_arg_bindings() {
let result = run_jq_with_args(
&[
"--arg",
"x",
"hello",
"--arg",
"y",
"world",
"-n",
r#""[\($x)] [\($y)]""#,
],
"",
)
.await
.unwrap();
assert_eq!(result.trim(), r#""[hello] [world]""#);
}
#[tokio::test]
async fn test_jq_combined_flags_snr() {
let result = run_jq_with_args(&["-snr", r#""hello""#], "").await.unwrap();
assert_eq!(result.trim(), "hello");
}
#[tokio::test]
async fn test_jq_raw_input() {
let result = run_jq_with_args(&["-R", "."], "hello\nworld\n")
.await
.unwrap();
assert_eq!(result.trim(), "\"hello\"\n\"world\"");
}
#[tokio::test]
async fn test_jq_raw_input_slurp() {
let result = run_jq_with_args(&["-Rs", "."], "hello\nworld\n")
.await
.unwrap();
assert_eq!(result.trim(), "\"hello\\nworld\\n\"");
}
#[tokio::test]
async fn test_jq_raw_input_split() {
let result = run_jq_with_args(
&["-Rs", r#"split("\n") | map(select(length>0))"#],
"a,b,c\n1,2,3\n",
)
.await
.unwrap();
assert!(result.contains("a,b,c"));
assert!(result.contains("1,2,3"));
}
#[tokio::test]
async fn test_jq_env_no_process_pollution() {
let unique_key = "BASHKIT_TEST_ENV_POLLUTION_410";
assert!(
std::env::var(unique_key).is_err(),
"precondition: {} must not exist in process env",
unique_key
);
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let mut env = HashMap::new();
env.insert(unique_key.to_string(), "leaked".to_string());
let args = vec!["-n".to_string(), format!("env.{}", unique_key)];
let ctx = Context {
args: &args,
env: &env,
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
let result = jq.execute(ctx).await.unwrap();
assert_eq!(result.stdout.trim(), "\"leaked\"");
assert!(
std::env::var(unique_key).is_err(),
"process env was polluted with shell variable {}",
unique_key
);
}
#[tokio::test]
async fn test_jq_env_no_host_leak() {
let unique_key = "BASHKIT_TEST_HOST_LEAK_410";
unsafe { std::env::set_var(unique_key, "host_secret") };
let jq = Jq;
let fs = Arc::new(InMemoryFs::new());
let mut vars = HashMap::new();
let mut cwd = PathBuf::from("/");
let args = vec!["-n".to_string(), format!("env.{}", unique_key)];
let ctx = Context {
args: &args,
env: &HashMap::new(),
variables: &mut vars,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
shell: None,
};
let result = jq.execute(ctx).await.unwrap();
assert_eq!(
result.stdout.trim(),
"null",
"host env var {} leaked into jq env",
unique_key
);
unsafe { std::env::remove_var(unique_key) };
}
#[tokio::test]
async fn test_jq_version_string() {
let result = run_jq_result_with_args(&["--version"], "null")
.await
.unwrap();
assert_eq!(result.stdout.trim(), "jq-1.8");
}
#[tokio::test]
async fn test_jq_abs() {
assert_eq!(run_jq("abs", "-42").await.unwrap().trim(), "42");
assert_eq!(run_jq("abs", "3.14").await.unwrap().trim(), "3.14");
assert_eq!(run_jq("abs", "-0.5").await.unwrap().trim(), "0.5");
}
#[tokio::test]
async fn test_jq_trim() {
assert_eq!(
run_jq("trim", r#"" hello ""#).await.unwrap().trim(),
r#""hello""#
);
}
#[tokio::test]
async fn test_jq_ltrim() {
assert_eq!(
run_jq("ltrim", r#"" hello ""#).await.unwrap().trim(),
r#""hello ""#
);
}
#[tokio::test]
async fn test_jq_rtrim() {
assert_eq!(
run_jq("rtrim", r#"" hello ""#).await.unwrap().trim(),
r#"" hello""#
);
}
#[tokio::test]
async fn test_jq_if_without_else() {
assert_eq!(
run_jq("if . > 0 then . * 2 end", "5").await.unwrap().trim(),
"10"
);
assert_eq!(
run_jq("if . > 0 then . * 2 end", "-1")
.await
.unwrap()
.trim(),
"-1"
);
}
#[tokio::test]
async fn test_jq_paths_with_filter() {
let result = run_jq("[paths(numbers)]", r#"{"a":1,"b":{"c":2},"d":"x"}"#)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(result.trim()).unwrap();
let arr = parsed.as_array().unwrap();
assert!(arr.iter().any(|v| v == &serde_json::json!(["a"])));
assert!(arr.iter().any(|v| v == &serde_json::json!(["b", "c"])));
}
#[tokio::test]
async fn test_jq_getpath() {
assert_eq!(
run_jq(r#"getpath(["a","b"])"#, r#"{"a":{"b":42}}"#)
.await
.unwrap()
.trim(),
"42"
);
}
}