use clap::Arg;
use cli_engine::{
Cli, CliConfig, CommandResult, CommandSpec, CredentialResolver, PaginationConfig,
RuntimeCommandSpec,
};
use serde_json::json;
fn items() -> Vec<serde_json::Value> {
vec![
json!({"name": "alpha"}),
json!({"name": "beta"}),
json!({"name": "gamma"}),
json!({"name": "delta"}),
]
}
fn cli_with_list_command(spec: CommandSpec) -> Cli {
let mut cli = Cli::new(CliConfig::new("my-cli", "Dev tooling", "my-cli"));
cli.add_command(RuntimeCommandSpec::new(spec, async |_credential, _args| {
Ok(CommandResult::new(json!(items())))
}));
cli
}
#[tokio::test]
async fn limit_and_offset_are_unknown_arguments_for_a_command_that_did_not_opt_in() {
let cli = cli_with_list_command(CommandSpec::new("list", "List things").no_auth(true));
let output = cli.run(["my-cli", "list", "--limit", "1"]).await;
assert_eq!(
output.exit_code, 2,
"unopted command should reject --limit as unknown: {}",
output.rendered
);
let output = cli.run(["my-cli", "list", "--offset", "1"]).await;
assert_eq!(
output.exit_code, 2,
"unopted command should reject --offset as unknown: {}",
output.rendered
);
let help = cli.run(["my-cli", "list", "--help"]).await;
assert!(
!help.rendered.contains("--limit") && !help.rendered.contains("--offset"),
"unopted command's --help should not mention pagination flags: {}",
help.rendered
);
}
#[tokio::test]
async fn opted_in_command_documents_limit_and_offset_in_help() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
max_limit: 3,
}),
);
let help = cli.run(["my-cli", "list", "--help"]).await;
assert!(help.rendered.contains("--limit"), "{}", help.rendered);
assert!(help.rendered.contains("--offset"), "{}", help.rendered);
}
#[tokio::test]
async fn default_limit_applies_when_neither_flag_is_passed() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli.run(["my-cli", "list", "--output", "json"]).await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["data"],
json!([{"name": "alpha"}, {"name": "beta"}])
);
assert_eq!(
rendered["pagination"],
json!({"total": 4, "offset": 0, "limit": 2, "count": 2, "has_more": true})
);
assert_eq!(
rendered["next_actions"][0]["command"],
"my-cli list --limit 2 --offset 2"
);
assert!(rendered.get("metadata").is_none(), "{}", output.rendered);
}
#[tokio::test]
async fn explicit_limit_and_offset_override_the_default_and_expose_pagination() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run([
"my-cli", "list", "--offset", "1", "--limit", "2", "--output", "json",
])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["data"],
json!([{"name": "beta"}, {"name": "gamma"}])
);
assert_eq!(
rendered["pagination"],
json!({"total": 4, "offset": 1, "limit": 2, "count": 2, "has_more": true})
);
assert_eq!(
rendered["next_actions"][0]["command"],
"my-cli list --limit 2 --offset 3"
);
assert_eq!(
rendered["next_actions"][0]["description"],
"View the next page (offset 3 of 4 total)"
);
}
#[tokio::test]
async fn last_page_has_no_next_action_and_has_more_is_false() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run([
"my-cli", "list", "--offset", "2", "--limit", "2", "--output", "json",
])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(rendered["pagination"]["has_more"], false);
assert!(
rendered.get("next_actions").is_none(),
"no next page exists: {}",
output.rendered
);
}
#[tokio::test]
async fn next_page_action_replays_other_flags_the_user_passed() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_arg(Arg::new("status").long("status"))
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run(["my-cli", "list", "--status", "active", "--output", "json"])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["next_actions"][0]["command"],
"my-cli list --status active --limit 2 --offset 2"
);
}
#[tokio::test]
async fn next_page_action_quotes_values_with_whitespace() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_arg(Arg::new("status").long("status"))
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run([
"my-cli",
"list",
"--status",
"in review",
"--output",
"json",
])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["next_actions"][0]["command"],
"my-cli list --status \"in review\" --limit 2 --offset 2"
);
}
#[tokio::test]
async fn next_page_action_quotes_a_binary_name_with_whitespace() {
let mut cli = Cli::new(CliConfig::new("my cli", "Dev tooling", "my-cli"));
cli.add_command(RuntimeCommandSpec::new(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
async |_credential, _args| Ok(CommandResult::new(json!(items()))),
));
let output = cli.run(["my cli", "list", "--output", "json"]).await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["next_actions"][0]["command"],
"\"my cli\" list --limit 2 --offset 2"
);
}
#[derive(Debug, Clone, clap::Args)]
struct ListArgs {
#[arg(long)]
sort_order: String,
}
#[tokio::test]
async fn next_page_action_uses_the_real_long_flag_not_the_value_map_key() {
let mut cli = Cli::new(CliConfig::new("my-cli", "Dev tooling", "my-cli"));
cli.add_command(RuntimeCommandSpec::new_typed::<ListArgs, _, _, _>(
CommandSpec::from_args::<ListArgs>("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
async |_credential: CredentialResolver, _args: ListArgs| {
Ok(CommandResult::new(json!(items())))
},
));
let output = cli
.run(["my-cli", "list", "--sort-order", "asc", "--output", "json"])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["next_actions"][0]["command"],
"my-cli list --sort-order asc --limit 2 --offset 2"
);
}
#[tokio::test]
async fn max_limit_rejects_an_explicit_limit_above_the_cap_but_allows_the_cap_itself() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
max_limit: 3,
..PaginationConfig::default()
}),
);
let output = cli.run(["my-cli", "list", "--limit", "4"]).await;
assert_eq!(
output.exit_code, 2,
"--limit above max_limit should be a usage error: {}",
output.rendered
);
let output = cli
.run(["my-cli", "list", "--limit", "3", "--output", "json"])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
}
#[tokio::test]
async fn max_limit_does_not_constrain_a_negative_limit() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
max_limit: 1,
..PaginationConfig::default()
}),
);
let output = cli
.run(["my-cli", "list", "--limit", "-1", "--output", "json"])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(rendered["data"], json!(items()));
}
#[tokio::test]
async fn negative_offset_is_rejected_at_parse_time_not_at_runtime() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig::default()),
);
let output = cli.run(["my-cli", "list", "--offset", "-1"]).await;
assert_eq!(
output.exit_code, 2,
"negative --offset should be a usage error: {}",
output.rendered
);
}
#[test]
#[cfg_attr(
debug_assertions,
should_panic(expected = "greater than its max_limit")
)]
fn with_pagination_panics_when_default_limit_exceeds_max_limit() {
let _unused = CommandSpec::new("list", "List things").with_pagination(PaginationConfig {
default_limit: 10,
max_limit: 5,
});
}
#[tokio::test]
async fn human_output_shows_pagination_summary_and_next_steps() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli.run(["my-cli", "list", "--output", "human"]).await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
assert!(
output.rendered.contains("(2 of 4 rows, offset 0, limit 2)"),
"{}",
output.rendered
);
assert!(
output.rendered.contains("Next steps:"),
"{}",
output.rendered
);
assert!(
output.rendered.contains("my-cli list --limit 2 --offset 2"),
"{}",
output.rendered
);
}
#[tokio::test]
async fn human_output_on_last_page_shows_summary_but_no_next_steps() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run([
"my-cli", "list", "--offset", "2", "--limit", "2", "--output", "human",
])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
assert!(
output.rendered.contains("(2 of 4 rows, offset 2, limit 2)"),
"{}",
output.rendered
);
assert!(
!output.rendered.contains("Next steps:"),
"no next page exists: {}",
output.rendered
);
}
#[tokio::test]
async fn next_page_action_preserves_filter_expr_and_fields() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run([
"my-cli",
"list",
"--filter",
"name != 'alpha'",
"--expr",
"sort_by(@, &name)",
"--fields",
"name",
"--output",
"json",
])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["next_actions"][0]["command"],
"my-cli list --filter \"name != 'alpha'\" --expr \"sort_by(@, &name)\" --fields name --limit 2 --offset 2"
);
}
#[tokio::test]
async fn next_page_action_replays_a_set_false_flag_as_a_bare_switch() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_arg(
Arg::new("no_cache")
.long("no-cache")
.action(clap::ArgAction::SetFalse),
)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run(["my-cli", "list", "--no-cache", "--output", "json"])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["next_actions"][0]["command"],
"my-cli list --no-cache --limit 2 --offset 2"
);
}
#[tokio::test]
async fn human_footer_shows_rows_actually_rendered_after_expr_reshapes_data() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run([
"my-cli",
"list",
"--expr",
"[?name=='alpha']",
"--output",
"human",
])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
assert!(
output.rendered.contains("(1 of 4 rows, offset 0, limit 2)"),
"{}",
output.rendered
);
}
#[tokio::test]
async fn next_page_action_replays_a_multi_value_arg_as_repeated_flags() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_arg(
Arg::new("scope")
.long("scope")
.action(clap::ArgAction::Append),
)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run([
"my-cli", "list", "--scope", "a", "--scope", "b", "--output", "json",
])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["next_actions"][0]["command"],
"my-cli list --scope a --scope b --limit 2 --offset 2"
);
}
#[tokio::test]
async fn human_standalone_summary_shows_rows_actually_rendered_after_expr_reshapes_data() {
let mut cli = Cli::new(CliConfig::new("my-cli", "Dev tooling", "my-cli"));
cli.add_command(RuntimeCommandSpec::new(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
async |_credential, _args| {
Ok(CommandResult::new(json!([
"alpha", "beta", "gamma", "delta"
])))
},
));
let output = cli
.run([
"my-cli",
"list",
"--expr",
"[?@=='alpha']",
"--output",
"human",
])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
assert!(
output
.rendered
.contains("Showing 1 of 4 (offset 0, limit 2)"),
"{}",
output.rendered
);
}
#[tokio::test]
async fn next_page_action_escapes_shell_metacharacters_and_expansions() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_arg(Arg::new("status").long("status"))
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run([
"my-cli",
"list",
"--status",
"a;$(whoami)`x`\"y\"\\z",
"--output",
"json",
])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
let rendered: serde_json::Value = serde_json::from_str(&output.rendered).expect("valid json");
assert_eq!(
rendered["next_actions"][0]["command"],
r#"my-cli list --status "a;\$(whoami)\`x\`\"y\"\\z" --limit 2 --offset 2"#
);
}
#[tokio::test]
async fn human_output_uses_a_neutral_pagination_line_when_expr_leaves_no_array() {
let cli = cli_with_list_command(
CommandSpec::new("list", "List things")
.no_auth(true)
.with_pagination(PaginationConfig {
default_limit: 2,
..PaginationConfig::default()
}),
);
let output = cli
.run(["my-cli", "list", "--expr", "length(@)", "--output", "human"])
.await;
assert_eq!(output.exit_code, 0, "{}", output.rendered);
assert!(
output
.rendered
.contains("(pagination: 4 total, offset 0, limit 2)"),
"{}",
output.rendered
);
assert!(!output.rendered.contains("Showing"), "{}", output.rendered);
}