use std::sync::{Arc, Mutex};
use serde::Serialize;
use serde_json::Value;
use xyz_rust::XyzArgs;
use crate::Ctx;
use crate::cli::render::{format_cell, render_value};
use crate::cli::{App, Options};
use crate::errors;
use crate::registry::Registry;
use crate::spec::command::Command;
#[derive(Clone, Default)]
struct Buf(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Buf {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Buf {
fn text(&self) -> String {
String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned()
}
}
#[derive(XyzArgs)]
struct EchoArgs {
#[xyz(desc = "内容", required)]
s: String,
#[xyz(desc = "次数", default = "1")]
n: i32,
}
fn echo(_: &Ctx, in_: &EchoArgs) -> errors::Result<String> {
Ok(in_.s.repeat(in_.n.max(1) as usize))
}
fn run_app(reg: &Registry, args: &[&str]) -> (i32, String, String) {
let out = Buf::default();
let err = Buf::default();
let mut a = App::new_with_options(
reg,
Options {
out: Some(Box::new(out.clone())),
err_out: Some(Box::new(err.clone())),
format: None,
},
)
.unwrap();
let argv: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = a.run(&argv);
(code, out.text(), err.text())
}
fn app_build_error(reg: &Registry) -> errors::Error {
match App::new(reg) {
Ok(_) => panic!("expected build error"),
Err(e) => e,
}
}
fn echo_reg() -> Registry {
let reg = Registry::new();
Command::new("echo.hi", echo).register(®).unwrap();
reg
}
#[test]
fn render_table_aligned() {
let v: Value = serde_json::to_value(vec![
serde_json::json!({"id": 1, "name": "a"}),
serde_json::json!({"id": 22, "name": "bb"}),
])
.unwrap();
let mut buf = Vec::new();
render_value(&mut buf, &v).unwrap();
let out = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "id name");
assert_eq!(lines[1], "-- ----");
assert_eq!(lines[2], "1 a ");
assert_eq!(lines[3], "22 bb ");
}
#[test]
fn render_scalars_and_kvs() {
let mut buf = Vec::new();
render_value(&mut buf, &Value::Null).unwrap();
assert!(buf.is_empty());
render_value(&mut buf, &serde_json::json!("hi")).unwrap();
render_value(&mut buf, &serde_json::json!(5)).unwrap();
render_value(&mut buf, &serde_json::json!(true)).unwrap();
render_value(&mut buf, &serde_json::json!(2.5)).unwrap();
render_value(&mut buf, &serde_json::json!([1, 2])).unwrap();
let out = String::from_utf8(buf).unwrap();
assert_eq!(out, "hi\n5\ntrue\n2.5\n1\n2\n");
let mut buf2 = Vec::new();
render_value(&mut buf2, &serde_json::json!(3.0)).unwrap();
assert_eq!(String::from_utf8(buf2).unwrap(), "3\n");
let mut buf3 = Vec::new();
render_value(&mut buf3, &serde_json::json!({"k": "v", "long": 1})).unwrap();
assert_eq!(String::from_utf8(buf3).unwrap(), "k v\nlong 1\n");
}
#[test]
fn format_cell_variants() {
assert_eq!(format_cell(&Value::Null), "");
assert_eq!(format_cell(&serde_json::json!([1, "a"])), "[1 a]");
assert_eq!(format_cell(&serde_json::json!({"a": 1})), "{\"a\":1}");
}
#[test]
fn cjk_padding_matches_go_width_semantics() {
let mut buf = Vec::new();
render_value(&mut buf, &serde_json::json!({"\u{540d}\u{5b57}": "v"})).unwrap();
let out = String::from_utf8(buf).unwrap();
assert_eq!(out, "\u{540d}\u{5b57} v\n");
}
#[test]
fn render_generic_serialize() {
let mut buf = Vec::new();
crate::cli::render(&mut buf, &serde_json::json!({"z": 1, "a": "x"})).unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "z 1\na x\n");
}
#[test]
fn app_echo_success() {
let reg = echo_reg();
let (code, out, _) = run_app(®, &["echo", "hi", "--s", "ab", "--n", "2"]);
assert_eq!(code, 0);
assert_eq!(out, "abab\n");
}
#[test]
fn app_missing_required_exits_2() {
let reg = echo_reg();
let (code, _, err) = run_app(®, &["echo", "hi"]);
assert_eq!(code, 2);
assert!(err.contains("required"), "{err}");
}
#[test]
fn app_unknown_flag_exits_2() {
let reg = echo_reg();
let (code, _, err) = run_app(®, &["echo", "hi", "--ghost"]);
assert_eq!(code, 2);
assert!(err.contains("unknown flag: --ghost"), "{err}");
}
#[test]
fn app_unknown_command_prints_help() {
let reg = echo_reg();
let (code, out, _) = run_app(®, &["echo"]);
assert_eq!(code, 0); assert!(out.contains("Usage:"), "{out}");
}
#[test]
fn app_version_flag() {
let reg = echo_reg();
let (code, out, _) = run_app(®, &["echo", "hi", "-v"]);
assert_eq!(code, 0);
assert!(out.contains("version"), "{out}");
}
#[test]
fn app_json_flag() {
let reg = echo_reg();
let (code, out, _) = run_app(®, &["echo", "hi", "--s", "x", "--json"]);
assert_eq!(code, 0);
assert_eq!(out, "\"x\"\n");
}
#[test]
fn completion_bash_contains_words() {
let reg = echo_reg();
let (code, out, _) = run_app(®, &["completion", "bash"]);
assert_eq!(code, 0);
assert!(out.contains("compgen -W"), "{out}");
assert!(out.contains("echo"), "{out}");
}
#[test]
fn completion_unknown_shell_exits_2() {
let reg = echo_reg();
let (code, _, _) = run_app(®, &["completion", "tcsh"]);
assert_eq!(code, 2);
}
#[derive(XyzArgs)]
struct AliasArgs {
#[xyz(desc = "v1", cli = "positional")]
a: String,
}
fn alias_h(_: &Ctx, in_: &AliasArgs) -> errors::Result<String> {
Ok(in_.a.clone())
}
#[test]
fn aliases_resolve_like_subcommands() {
let reg = Registry::new();
Command::new("user.add", alias_h)
.cli(crate::CliHints {
usage: "add <a>".into(),
aliases: vec!["ua".into()],
..Default::default()
})
.register(®)
.unwrap();
let (code, out, _) = run_app(®, &["user", "ua", "x"]);
assert_eq!(code, 0);
assert_eq!(out, "x\n");
}
#[test]
fn conflicting_alias_rejected_at_build() {
let reg = Registry::new();
Command::new("a.one", alias_h).register(®).unwrap();
Command::new("a.two", alias_h)
.cli(crate::CliHints {
aliases: vec!["one".into()],
..Default::default()
})
.register(®)
.unwrap();
let err = app_build_error(®);
assert!(err.to_string().contains("alias"), "{err}");
}
#[test]
fn optional_after_required_positional_rejected() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct P {
#[xyz(desc = "a", cli = "positional")]
a: String,
#[xyz(desc = "b", required, cli = "positional")]
b: String,
}
fn ph(_: &Ctx, _: &P) -> errors::Result<String> {
Ok(String::new())
}
Command::new("p.x", ph).register(®).unwrap();
let err = app_build_error(®);
assert!(
err.to_string().contains("must not follow optional"),
"{err}"
);
}
#[test]
fn nested_struct_field_rejected_for_cli() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct Sub {
#[xyz(desc = "x")]
x: i32,
}
#[derive(XyzArgs)]
struct Top {
#[xyz(desc = "s")]
sub: Sub,
}
fn th(_: &Ctx, _: &Top) -> errors::Result<String> {
Ok(String::new())
}
Command::new("t.x", th).register(®).unwrap();
let err = app_build_error(®);
assert!(err.to_string().contains("not supported"), "{err}");
}
#[test]
fn middleware_chain_sees_args_and_can_short_circuit() {
let reg = echo_reg();
let mut a = App::new(®).unwrap();
use crate::cli::ExecContext;
let seen = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let seen2 = std::sync::Arc::clone(&seen);
a.use_mw(Box::new(move |_ctx, ec: &ExecContext, args, next| {
assert_eq!(ec.path, "echo.hi");
assert!(args.contains_key("s"));
seen2.store(true, std::sync::atomic::Ordering::Relaxed);
next(args) }));
let argv: Vec<String> = ["echo", "hi", "--s", "x"]
.iter()
.map(|s| s.to_string())
.collect();
let code = a.run(&argv);
assert_eq!(code, 0);
assert!(seen.load(std::sync::atomic::Ordering::Relaxed));
}
#[test]
fn default_subcommand_forwards_all_args() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct ExtractArgs {
#[xyz(desc = "归档", required, cli = "positional")]
name: String,
#[xyz(desc = "级别", default = "0")]
level: i32,
}
fn extract(_: &Ctx, in_: &ExtractArgs) -> errors::Result<String> {
Ok(format!("{}:{}", in_.name, in_.level))
}
Command::new("extract", extract)
.cli(crate::CliHints {
usage: "extract <name>".into(),
default: true,
..Default::default()
})
.register(®)
.unwrap();
let (code, out, _) = run_app(®, &["./image.tar", "--level", "9"]);
assert_eq!(code, 0);
assert_eq!(out, "./image.tar:9\n");
let (code, out, _) = run_app(®, &["extract", "tarball.tar"]);
assert_eq!(code, 0);
assert_eq!(out, "tarball.tar:0\n");
let (code, out, _) = run_app(®, &["-h"]);
assert_eq!(code, 0);
assert!(out.contains("Usage:"), "{out}");
assert!(out.contains("extract"), "{out}");
let (code, out, _) = run_app(®, &["img.tar", "-h"]);
assert_eq!(code, 0);
assert!(out.contains("extract <name> [flags]"), "{out}");
}
#[test]
fn help_blocks_on_leaf_only() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct E {
#[xyz(desc = "x")]
x: String,
}
fn eh(_: &Ctx, _: &E) -> errors::Result<String> {
Ok(String::new())
}
Command::new("extract", eh)
.cli(crate::CliHints {
before: "extract — 解包镜像\n用法示例见下方".into(),
after: "更多: https://example.com/udf#extract".into(),
..Default::default()
})
.register(®)
.unwrap();
let (code, out, _) = run_app(®, &["extract", "-h"]);
assert_eq!(code, 0);
assert!(out.starts_with("extract — 解包镜像\n"), "{out}");
assert!(
out.ends_with("更多: https://example.com/udf#extract\n"),
"{out}"
);
let reg2 = Registry::new();
Command::new("user.add", eh)
.cli(crate::CliHints {
before: "LEAFBLK".into(),
..Default::default()
})
.register(®2)
.unwrap();
let (_, out2, _) = run_app(®2, &["user", "-h"]);
assert!(!out2.contains("LEAFBLK"), "{out2}");
let (code3, out3, _) = run_app(®2, &["user", "add", "-h"]);
assert_eq!(code3, 0);
assert!(out3.starts_with("LEAFBLK\n"), "{out3}");
}
#[test]
fn duplicate_default_rejected_at_build() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct X {
#[xyz(desc = "x")]
x: String,
}
fn xh(_: &Ctx, _: &X) -> errors::Result<String> {
Ok(String::new())
}
Command::new("a.one", xh)
.cli(crate::CliHints {
default: true,
..Default::default()
})
.register(®)
.unwrap();
Command::new("a.two", xh)
.cli(crate::CliHints {
default: true,
..Default::default()
})
.register(®)
.unwrap();
let err = app_build_error(®);
assert!(err.to_string().contains("default conflicts"), "{err}");
}
#[test]
fn double_dash_terminator_guards_v_and_json() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct P {
#[xyz(desc = "a", required, cli = "positional")]
a: String,
}
fn ph(_: &Ctx, in_: &P) -> errors::Result<String> {
Ok(in_.a.clone())
}
Command::new("user.add", ph).register(®).unwrap();
let (code, out, _) = run_app(®, &["user", "add", "--", "-v"]);
assert_eq!(code, 0);
assert_eq!(out, "-v\n");
let (code, out, _) = run_app(®, &["user", "add", "--", "--json"]);
assert_eq!(code, 0);
assert_eq!(out, "--json\n");
let (code, _, _) = run_app(®, &["-v"]);
assert_eq!(code, 0);
}
#[test]
fn positional_min_max() {
let reg = Registry::new();
#[derive(XyzArgs)]
struct Pos {
#[xyz(desc = "a", required, cli = "positional")]
a: String,
#[xyz(desc = "b", cli = "positional")]
b: String,
}
fn ph(_: &Ctx, in_: &Pos) -> errors::Result<String> {
Ok(format!("{}:{}", in_.a, in_.b))
}
Command::new("p.x", ph).register(®).unwrap();
let (code, out, _) = run_app(®, &["p", "x", "one", "two"]);
assert_eq!(code, 0);
assert_eq!(out, "one:two\n");
let (code, _, err) = run_app(®, &["p", "x"]);
assert_eq!(code, 2);
assert!(err.contains("positional argument count mismatch"), "{err}");
}
#[derive(Serialize, XyzArgs)]
struct RowOut {
#[xyz(desc = "编号")]
id: i64,
#[xyz(desc = "名")]
name: String,
}
fn _table(_: &Ctx, _: &EchoArgs) -> errors::Result<Vec<RowOut>> {
Ok(vec![])
}
#[test]
fn unused_serialize_bound_smoke() {
fn f(_: &Ctx, _: &EchoArgs) -> errors::Result<Vec<RowOut>> {
Ok(vec![RowOut {
id: 1,
name: "a".into(),
}])
}
let e = Command::new("rows.list", f).entry().unwrap();
assert!(e.output_schema.is_some());
}
#[test]
fn block_envelope_spills_binary_to_files() {
let env = serde_json::json!({"content":[
{"type":"text","text":"hello"},
{"type":"image","mimeType":"image/png","data":"aGVsbG8="}
]});
let mut buf = Vec::new();
crate::cli::render::render_value(&mut buf, &env).unwrap();
let out = String::from_utf8(buf).unwrap();
let mut lines = out.lines();
assert_eq!(lines.next(), Some("hello"));
let path = lines.next().unwrap().trim();
assert!(path.contains("xyz-blk-"), "path = {path}");
assert!(path.ends_with(".png"), "path = {path}");
let bytes = std::fs::read(path).unwrap();
assert_eq!(bytes, b"hello");
std::fs::remove_file(path).ok();
}
#[test]
fn union_field_degrades_not_fatals() {
use crate::Ctx;
use crate::registry::Registry;
use crate::spec::XyzField;
use crate::spec::command::Command;
#[derive(serde::Serialize, serde::Deserialize, xyz_rust::XyzArgs)]
#[serde(tag = "type")]
enum Sel {
ById { id: i64 },
ByName { name: String },
}
#[derive(xyz_rust::XyzArgs)]
struct PickArgs {
sel: Option<Sel>,
#[xyz(desc = "标记", required)]
tag_note: String,
}
fn pick(_: &Ctx, in_: &PickArgs) -> crate::errors::Result<String> {
Ok(format!(
"{}/{}",
in_.tag_note,
in_.sel.as_ref().map(|_| "sel").unwrap_or("no-sel")
))
}
let reg = Registry::new();
Command::new("demo.pick", pick).register(®).unwrap();
let entry = reg.get("demo.pick").unwrap();
let names: Vec<String> = entry
.root
.children
.iter()
.map(|c| format!("{}:{}", c.json_name, c.kind.schema_type()))
.collect();
let (_hcode, hout, _herr) = run_app(®, &["demo", "pick", "-h"]);
let (code, _out, err) = run_app(®, &["demo", "pick", "--tag_note", "hi"]);
assert_eq!(
code, 0,
"command with union field must run (union skipped); stderr: {err}; help: {hout}; fields: {names:?}"
);
}
#[test]
fn format_dispatch_and_conflict_yield() {
use crate::Ctx;
use crate::registry::Registry;
use crate::spec::command::Command;
#[derive(xyz_rust::XyzArgs)]
struct FArgs {
#[xyz(desc = "n")]
n: i64,
}
fn list(_: &Ctx, _: &FArgs) -> crate::errors::Result<Vec<i64>> {
Ok(vec![1, 2])
}
let reg = Registry::new();
Command::new("f.list", list).register(®).unwrap();
let (code, out, err) = run_app(®, &["f", "list", "--format", "jsonl", "--n", "1"]);
assert_eq!(code, 0, "err: {err}");
assert_eq!(out, "1\n2\n");
let (code, out, _) = run_app(®, &["f", "list", "--format=markdown", "--n", "1"]);
assert_eq!(code, 0);
assert_eq!(out, "- 1\n- 2\n");
let out_buf = Buf::default();
let err_buf = Buf::default();
let mut a = App::new_with_options(
®,
Options {
out: Some(Box::new(out_buf.clone())),
err_out: Some(Box::new(err_buf.clone())),
format: Some("json".into()),
},
)
.unwrap();
let argv: Vec<String> = ["f", "list", "--n", "1"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(a.run(&argv), 0);
assert!(
out_buf.text().trim_start().starts_with('['),
"{}",
out_buf.text()
);
let (code, _o, e) = run_app(®, &["f", "list", "--format", "bogus"]);
assert_eq!(code, 2);
assert!(e.contains("invalid output format"), "{e}");
#[derive(xyz_rust::XyzArgs)]
struct BoomArgs {
#[xyz(desc = "n")]
n: i64,
}
fn boom(_: &Ctx, _: &BoomArgs) -> crate::errors::Result<String> {
Err(
crate::errors::Error::new(crate::errors::Kind::NotFound, "gone")
.with_code("GONE")
.with_detail("id", serde_json::json!(7)),
)
}
Command::new("f.boom", boom).register(®).unwrap();
let (code, _o, err) = run_app(®, &["f", "boom", "--format", "json", "--n", "1"]);
assert_eq!(code, 1);
let v: serde_json::Value = serde_json::from_str(err.trim()).unwrap();
assert_eq!(v["error"], "gone");
assert_eq!(v["kind"], "not_found");
assert_eq!(v["code"], "GONE");
assert_eq!(v["detail"]["id"], 7);
let (code, _o, err) = run_app(®, &["f", "boom", "--n", "1"]);
assert_eq!(code, 1);
assert!(!err.trim_start().starts_with('{'), "{err}");
}
#[test]
fn format_flag_yields_to_command_field() {
use crate::Ctx;
use crate::registry::Registry;
use crate::spec::command::Command;
#[derive(xyz_rust::XyzArgs)]
struct HasFormatArgs {
#[xyz(desc = "fmt")]
format: String,
}
fn echo(_: &Ctx, a: &HasFormatArgs) -> crate::errors::Result<String> {
Ok(a.format.clone())
}
let reg = Registry::new();
Command::new("g.echo", echo).register(®).unwrap();
let (code, out, err) = run_app(®, &["g", "echo", "--format", "json"]);
assert_eq!(code, 0, "err: {err}");
assert_eq!(out.trim(), "json");
}