Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 107 additions & 7 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ use crate::{
RuntimeGroupSpec,
auth::commands::auth_command_group,
command::{
CommandContext, StreamSender, command_args_from_matches, command_path_from_matches,
leaf_matches,
BareGroupAction, CommandContext, StreamSender, command_args_from_matches,
command_path_from_matches, leaf_matches,
},
error::exit_code_for_error,
feature_flags::{FlagEntry, FlagPolicy, FlagRegistry, Stage},
Expand Down Expand Up @@ -827,6 +827,7 @@ pub struct Cli {
middleware: Middleware,
root: Command,
commands: BTreeMap<String, RuntimeCommandSpec>,
group_actions: BTreeMap<String, BareGroupAction>,
module_entries: Vec<ModuleHelpEntry>,
guide_entries: Vec<GuideEntry>,
init_deps: Option<InitDeps>,
Expand Down Expand Up @@ -1057,6 +1058,7 @@ impl Cli {
middleware,
root,
commands: BTreeMap::new(),
group_actions: BTreeMap::new(),
module_entries: Vec::new(),
guide_entries: Vec::new(),
init_deps,
Expand Down Expand Up @@ -1293,7 +1295,7 @@ impl Cli {
&mut self.middleware.human_views,
);
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
group.register_commands(&mut prefix, &mut self.commands, &mut self.group_actions);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
Expand Down Expand Up @@ -1803,6 +1805,22 @@ impl Cli {
return self.finish_run(self.render_completion_print(shell_opt, &middleware));
}
let Some(command) = self.commands.get(&command_path) else {
if !command_path.is_empty()
&& let Some(action) = self.group_actions.get(&command_path)
{
if let Err(err) = self.run_pre_run(
&mut middleware,
&command_path,
&crate::middleware::ValueMap::new(),
) {
return self.finish_run(render_cli_error(
&middleware,
&err,
&self.config.app_id,
));
}
return self.finish_run(self.render_bare_group_action(action, &middleware));
}
if !command_path.is_empty()
&& let Some(group) = find_command_by_colon_path(&self.root, &command_path)
&& group.get_subcommands().next().is_some()
Expand Down Expand Up @@ -2000,6 +2018,38 @@ impl Cli {
}
}

/// Renders a group's `bare_action` callback result as a JSON envelope,
/// replacing the default bare-group help text. Mirrors [`Self::render_search`]'s
/// format resolution and envelope construction.
fn render_bare_group_action(
&self,
action: &BareGroupAction,
middleware: &Middleware,
) -> CliRunOutput {
let format: crate::output::OutputFormat = match middleware.output_format.parse() {
Ok(format) => format,
Err(err) => {
return CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
};
}
};
let data = action();
let envelope = crate::Envelope::success(data, self.config.app_id.clone())
.prepare_for_render(&middleware.verbose);
match crate::output::render(format, &envelope) {
Ok(rendered) => CliRunOutput {
exit_code: 0,
rendered,
},
Err(err) => CliRunOutput {
exit_code: exit_code_for_error(&err),
rendered: err.to_string(),
},
}
}

fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput {
let format: crate::output::OutputFormat = match output_format.parse() {
Ok(format) => format,
Expand Down Expand Up @@ -2325,7 +2375,7 @@ impl Cli {
&mut self.middleware.human_views,
);
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
group.register_commands(&mut prefix, &mut self.commands, &mut self.group_actions);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
Expand All @@ -2352,7 +2402,7 @@ impl Cli {
}
let group = crate::config_commands::config_command_group();
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
group.register_commands(&mut prefix, &mut self.commands, &mut self.group_actions);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
Expand Down Expand Up @@ -2388,7 +2438,7 @@ impl Cli {
}
let group = crate::env_commands::env_command_group();
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
group.register_commands(&mut prefix, &mut self.commands, &mut self.group_actions);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
Expand Down Expand Up @@ -2422,7 +2472,7 @@ impl Cli {
}
let group = crate::flag_commands::flags_command_group();
let mut prefix = Vec::new();
group.register_commands(&mut prefix, &mut self.commands);
group.register_commands(&mut prefix, &mut self.commands, &mut self.group_actions);
let mut prefix = Vec::new();
let clap_group = runtime_group_clap_command_with_schema_help(
&group,
Expand Down Expand Up @@ -4564,3 +4614,53 @@ mod flags_command_tests {
assert!(out.rendered.contains("no such flag"));
}
}

#[cfg(test)]
mod bare_group_action_tests {
use super::*;
use crate::CommandResult;

fn group_with_bare_action() -> RuntimeGroupSpec {
RuntimeGroupSpec::new(GroupSpec::new("widgets", "Manage widgets"))
.with_bare_action(|| {
serde_json::json!({
"command": "bartest widgets",
"commands": ["bartest widgets list"],
})
})
.with_command(RuntimeCommandSpec::new(
CommandSpec::new("list", "List widgets").no_auth(true),
async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
))
}

#[tokio::test]
async fn bare_group_with_action_renders_json_instead_of_help() {
let mut cli = Cli::new(CliConfig::new("bartest", "Bar test", "bartest"));
cli.add_module_group("Test Category", group_with_bare_action());

let out = cli.run(["bartest", "widgets", "--output", "json"]).await;
assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
let rendered: serde_json::Value =
serde_json::from_str(&out.rendered).expect("stdout should contain json");
assert_eq!(rendered["data"]["command"], "bartest widgets");
}

#[tokio::test]
async fn bare_group_without_action_still_renders_help() {
let mut cli = Cli::new(CliConfig::new("bartest2", "Bar test", "bartest2"));
cli.add_module_group(
"Test Category",
RuntimeGroupSpec::new(GroupSpec::new("widgets", "Manage widgets")).with_command(
RuntimeCommandSpec::new(
CommandSpec::new("list", "List widgets").no_auth(true),
async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
),
),
);

let out = cli.run(["bartest2", "widgets"]).await;
assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
assert!(out.rendered.contains("Manage widgets"));
}
}
41 changes: 39 additions & 2 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ pub type StreamingCommandFuture = Pin<Box<dyn Future<Output = Result<()>> + Send
pub type StreamingCommandHandler =
Arc<dyn Fn(CommandContext, StreamSender) -> StreamingCommandFuture + Send + Sync>;

/// Callback invoked when a group is run bare (no subcommand). Returns the
/// JSON value rendered in place of the default group help text; see
/// [`RuntimeGroupSpec::with_bare_action`].
pub type BareGroupAction = Arc<dyn Fn() -> Value + Send + Sync>;

/// Data returned by a command handler.
///
/// Command handlers should return renderable data and keep output metadata on
Expand Down Expand Up @@ -1091,7 +1096,7 @@ impl RuntimeCommandSpec {
/// Construct with [`RuntimeGroupSpec::new`], then chain `with_*` methods —
/// never as a struct literal. `#[non_exhaustive]` enforces this so the engine
/// can add fields without a breaking release.
#[derive(Clone, Debug, Default)]
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct RuntimeGroupSpec {
/// Declarative group metadata.
Expand All @@ -1100,6 +1105,22 @@ pub struct RuntimeGroupSpec {
pub commands: Vec<RuntimeCommandSpec>,
/// Executable nested groups under this group.
pub groups: Vec<RuntimeGroupSpec>,
/// Optional callback invoked when this group is run bare (no
/// subcommand). When set, its return value is rendered as this
/// invocation's JSON envelope instead of the default group help text.
pub bare_action: Option<BareGroupAction>,
}

impl std::fmt::Debug for RuntimeGroupSpec {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RuntimeGroupSpec")
.field("group", &self.group)
.field("commands", &self.commands)
.field("groups", &self.groups)
.field("has_bare_action", &self.bare_action.is_some())
.finish()
}
}

impl RuntimeGroupSpec {
Expand All @@ -1126,6 +1147,18 @@ impl RuntimeGroupSpec {
self
}

/// Sets the callback invoked when this group is run bare (no
/// subcommand), replacing the default rendered help text with the
/// callback's JSON return value.
#[must_use]
pub fn with_bare_action<F>(mut self, action: F) -> Self
where
F: Fn() -> Value + Send + Sync + 'static,
{
self.bare_action = Some(Arc::new(action));
self
}

/// Builds the `clap` command for parser registration.
#[must_use]
pub fn clap_command(&self) -> Command {
Expand Down Expand Up @@ -1154,10 +1187,14 @@ impl RuntimeGroupSpec {
&self,
prefix: &mut Vec<String>,
out: &mut BTreeMap<String, RuntimeCommandSpec>,
group_actions: &mut BTreeMap<String, BareGroupAction>,
) {
prefix.push(self.group.name.clone());
if let Some(action) = &self.bare_action {
group_actions.insert(prefix.join(":"), Arc::clone(action));
}
for group in &self.groups {
group.register_commands(prefix, out);
group.register_commands(prefix, out, group_actions);
}
for command in &self.commands {
prefix.push(command.spec.name.clone());
Expand Down
6 changes: 3 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,9 @@ pub use cli::{
RootNextActions, build_root_long,
};
pub use command::{
CommandContext, CommandFuture, CommandHandler, CommandResult, CommandResultMetadata,
CommandSpec, GroupSpec, RuntimeCommandSpec, RuntimeGroupSpec, StreamSender,
StreamingCommandFuture, StreamingCommandHandler, command_args_from_matches,
BareGroupAction, CommandContext, CommandFuture, CommandHandler, CommandResult,
CommandResultMetadata, CommandSpec, GroupSpec, RuntimeCommandSpec, RuntimeGroupSpec,
StreamSender, StreamingCommandFuture, StreamingCommandHandler, command_args_from_matches,
command_path_from_matches, command_path_from_parts, leaf_matches,
};
pub use config::{
Expand Down