diff --git a/src/cli.rs b/src/cli.rs index 594748f..e4d8626 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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}, @@ -827,6 +827,7 @@ pub struct Cli { middleware: Middleware, root: Command, commands: BTreeMap, + group_actions: BTreeMap, module_entries: Vec, guide_entries: Vec, init_deps: Option, @@ -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, @@ -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, @@ -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() @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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")); + } +} diff --git a/src/command.rs b/src/command.rs index fafdd55..fed7287 100644 --- a/src/command.rs +++ b/src/command.rs @@ -37,6 +37,11 @@ pub type StreamingCommandFuture = Pin> + Send pub type StreamingCommandHandler = Arc 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 Value + Send + Sync>; + /// Data returned by a command handler. /// /// Command handlers should return renderable data and keep output metadata on @@ -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. @@ -1100,6 +1105,22 @@ pub struct RuntimeGroupSpec { pub commands: Vec, /// Executable nested groups under this group. pub groups: Vec, + /// 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, +} + +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 { @@ -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(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 { @@ -1154,10 +1187,14 @@ impl RuntimeGroupSpec { &self, prefix: &mut Vec, out: &mut BTreeMap, + group_actions: &mut BTreeMap, ) { 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()); diff --git a/src/lib.rs b/src/lib.rs index d34be1a..09577e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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::{