use apexe::module::executor::{build_arguments, execute_subprocess, DEFAULT_MAX_OUTPUT_BYTES};
use serde_json::{json, Value};
const CLOSED_PORT_URL: &str = "http://127.0.0.1:9/";
const CURL_EXIT_UNKNOWN_OPTION: i32 = 2;
fn kwargs(pairs: &[(&str, Value)]) -> serde_json::Map<String, Value> {
pairs
.iter()
.map(|(key, value)| ((*key).to_string(), value.clone()))
.collect()
}
fn binary_exists(path: &str) -> bool {
std::path::Path::new(path).exists()
}
fn find_is_bsd() -> bool {
std::process::Command::new("/usr/bin/find")
.arg("--version")
.output()
.is_ok_and(|out| !out.status.success())
}
fn curl_schema() -> Value {
json!({
"type": "object",
"properties": {
"url": { "type": "string", "x-apexe-positional": 0 },
"max_time": { "type": "number", "x-apexe-flag": "--max-time" },
"user_agent": { "type": "string", "x-apexe-flag": "--user-agent" },
}
})
}
fn find_schema(end_of_options: bool) -> Value {
let mut schema = json!({
"type": "object",
"properties": {
"path": {
"type": "array",
"x-apexe-positional": 0,
"x-apexe-operand-position": "before-flags"
},
"expression": { "type": "array", "x-apexe-positional": 1 },
"f": {
"type": "array",
"x-apexe-flag": "-f",
"x-apexe-flag-position": "before-operands"
},
"name": { "type": "string", "x-apexe-flag": "-name" },
}
});
if end_of_options {
schema["x-apexe-end-of-options"] = json!(true);
}
schema
}
#[tokio::test]
async fn test_curl_accepts_a_separated_long_option_value() {
if !binary_exists("/usr/bin/curl") {
eprintln!("skipping: no /usr/bin/curl on this host");
return;
}
let args = build_arguments(
&kwargs(&[
("url", json!(CLOSED_PORT_URL)),
("max_time", json!(1)),
("user_agent", json!("apexe")),
]),
Some(&curl_schema()),
)
.expect("a curl invocation with two valued long options must render");
assert!(
!args.iter().any(|arg| arg.starts_with("--max-time=")),
"the attached spelling is the defect: {args:?}"
);
let out = execute_subprocess(
"/usr/bin/curl",
&args,
None,
10_000,
DEFAULT_MAX_OUTPUT_BYTES,
)
.await
.expect("curl should run");
assert_ne!(
out.exit_code, CURL_EXIT_UNKNOWN_OPTION,
"curl rejected an option apexe rendered: {}",
out.stderr
);
assert!(
!out.stderr.contains("is unknown"),
"curl rejected an option apexe rendered: {}",
out.stderr
);
}
#[tokio::test]
async fn test_curl_refuses_an_attached_long_option_value() {
if !binary_exists("/usr/bin/curl") {
eprintln!("skipping: no /usr/bin/curl on this host");
return;
}
let args = vec!["--max-time=1".to_string(), CLOSED_PORT_URL.to_string()];
let out = execute_subprocess(
"/usr/bin/curl",
&args,
None,
10_000,
DEFAULT_MAX_OUTPUT_BYTES,
)
.await
.expect("curl should run");
assert_eq!(
out.exit_code, CURL_EXIT_UNKNOWN_OPTION,
"curl was expected to refuse `--max-time=1`: {out:?}"
);
assert!(
out.stderr.contains("--max-time=1"),
"curl should name the option it refused: {}",
out.stderr
);
}
#[tokio::test]
async fn test_find_expression_operand_reaches_the_binary() {
if !binary_exists("/usr/bin/find") {
eprintln!("skipping: no /usr/bin/find on this host");
return;
}
let sandbox = tempfile::tempdir().expect("sandbox");
std::fs::write(sandbox.path().join("wanted.txt"), b"x").unwrap();
std::fs::write(sandbox.path().join("ignored.log"), b"x").unwrap();
let root = sandbox.path().to_str().unwrap().to_string();
let args = build_arguments(
&kwargs(&[
("path", json!([root])),
("expression", json!(["-name", "*.txt"])),
]),
Some(&find_schema(true)),
)
.expect("an expression of primaries must render once `--` is available");
assert_eq!(
args.first().map(String::as_str),
Some("--"),
"the separator belongs ahead of the paths: {args:?}"
);
let out = execute_subprocess(
"/usr/bin/find",
&args,
None,
10_000,
DEFAULT_MAX_OUTPUT_BYTES,
)
.await
.expect("find should run");
assert_eq!(
out.exit_code,
0,
"find rejected {args:?}: {}",
out.stderr.trim()
);
assert!(
out.stdout.contains("wanted.txt") && !out.stdout.contains("ignored.log"),
"the expression did not take effect: {out:?}"
);
}
#[tokio::test]
async fn test_find_f_option_carries_a_dash_leading_path() {
if !binary_exists("/usr/bin/find") || !find_is_bsd() {
eprintln!("skipping: `-f` is a BSD find option and this host has neither");
return;
}
let sandbox = tempfile::tempdir().expect("sandbox");
let weird = sandbox.path().join("-weird-dir");
std::fs::create_dir(&weird).unwrap();
std::fs::write(weird.join("wanted.txt"), b"x").unwrap();
let args = build_arguments(
&kwargs(&[("f", json!(["-weird-dir"])), ("name", json!("*.txt"))]),
Some(&find_schema(true)),
)
.expect("`-f` exists precisely for a path that begins with '-'");
assert_eq!(
args,
vec!["-f", "-weird-dir", "--", "-name", "*.txt"],
"the separator belongs after the pre-operand flags"
);
let mut command = std::process::Command::new("/usr/bin/find");
let out = command
.current_dir(sandbox.path())
.args(&args)
.output()
.expect("find should run");
assert!(
out.status.success(),
"find rejected {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("wanted.txt"),
"the expression did not take effect: {}",
String::from_utf8_lossy(&out.stdout)
);
}
fn ls_binary() -> Option<&'static str> {
["/bin/ls", "/usr/bin/ls"]
.into_iter()
.find(|path| binary_exists(path))
}
fn listable_dir() -> tempfile::TempDir {
let dir = tempfile::TempDir::new().expect("temp dir");
std::fs::create_dir(dir.path().join("sub")).expect("subdirectory");
std::fs::write(dir.path().join("plain.txt"), b"x").expect("file");
dir
}
fn ls_color_schema(value_optional: bool) -> Value {
let mut color = json!({ "type": "string", "x-apexe-flag": "--color" });
if value_optional {
color = json!({
"type": ["string", "boolean"],
"x-apexe-flag": "--color",
"x-apexe-value-optional": true,
});
}
json!({
"type": "object",
"properties": {
"color": color,
"file": { "type": "array", "x-apexe-positional": 0 },
}
})
}
#[tokio::test]
async fn test_ls_optional_value_flag_renders_attached_and_takes_effect() {
let Some(ls) = ls_binary() else {
eprintln!("skipping: no `ls` on this host");
return;
};
let dir = listable_dir();
let path = dir.path().to_string_lossy().to_string();
let render = |when: &str| {
build_arguments(
&kwargs(&[("color", json!(when)), ("file", json!([path.clone()]))]),
Some(&ls_color_schema(true)),
)
.expect("an `ls --color` invocation must render")
};
let always = render("always");
assert!(
always.contains(&"--color=always".to_string()),
"an optional value must be attached: {always:?}"
);
let coloured = execute_subprocess(ls, &always, None, 10_000, DEFAULT_MAX_OUTPUT_BYTES)
.await
.expect("ls should run");
if coloured.exit_code != 0 {
eprintln!(
"skipping: this host's `ls` has no --color: {}",
coloured.stderr
);
return;
}
assert!(
coloured.stdout.contains('\x1b'),
"`--color=always` must colourise, or the probe below proves nothing: {:?}",
coloured.stdout
);
let plain = execute_subprocess(ls, &render("never"), None, 10_000, DEFAULT_MAX_OUTPUT_BYTES)
.await
.expect("ls should run");
assert_eq!(
plain.exit_code, 0,
"ls rejected `--color=never`: {}",
plain.stderr
);
assert!(
!plain.stdout.contains('\x1b'),
"`--color=never` must disable colour: {:?}",
plain.stdout
);
}
#[tokio::test]
async fn test_ls_separated_value_is_lost_and_inverts_the_request() {
let Some(ls) = ls_binary() else {
eprintln!("skipping: no `ls` on this host");
return;
};
let dir = listable_dir();
let path = dir.path().to_string_lossy().to_string();
let args = build_arguments(
&kwargs(&[("color", json!("never")), ("file", json!([path]))]),
Some(&ls_color_schema(false)),
)
.expect("the unmarked schema must still render");
assert_eq!(
args.iter().filter(|arg| *arg == "never").count(),
1,
"without the marker the word is a separate argv entry: {args:?}"
);
let out = execute_subprocess(ls, &args, None, 10_000, DEFAULT_MAX_OUTPUT_BYTES)
.await
.expect("ls should run");
if !out.stderr.contains("never") {
eprintln!("skipping: this host's `ls` has no --color: {}", out.stderr);
return;
}
assert_ne!(
out.exit_code, 0,
"`--color never` was expected to fail on the phantom operand: {out:?}"
);
assert!(
out.stdout.contains('\x1b'),
"the request was inverted, not dropped: a bare `--color` colourises: {:?}",
out.stdout
);
}
#[test]
fn test_option_like_values_stay_refused_without_the_marker() {
let err = build_arguments(
&kwargs(&[
("path", json!(["/tmp"])),
("expression", json!(["-name", "*.txt"])),
]),
Some(&find_schema(false)),
)
.expect_err("without the marker the guard must still refuse");
assert_eq!(err.code, apcore::ErrorCode::GeneralInvalidInput);
assert!(
err.message.contains("Element 0 of parameter 'expression'"),
"the message must name the offending element: {}",
err.message
);
}