use std::{collections::BTreeMap, future::Future, pin::Pin, sync::Arc};
use clap::{Arg, ArgAction, ArgMatches, Command};
use schemars::JsonSchema;
use serde_json::{Number, Value};
use tokio::sync::mpsc;
use crate::{
AuthRequirement, CommandMeta, Credential, CredentialResolver, Middleware, OutputSchema, Result,
SchemaInfo, Tier,
middleware::ValueMap,
output::{NextAction, TableColumn},
};
#[derive(Clone, Debug)]
pub struct StreamSender(pub(crate) mpsc::Sender<Value>);
impl StreamSender {
pub async fn send(&self, event: Value) {
drop(self.0.send(event).await);
}
}
pub type CommandFuture = Pin<Box<dyn Future<Output = Result<CommandResult>> + Send>>;
pub type CommandHandler = Arc<dyn Fn(CommandContext) -> CommandFuture + Send + Sync>;
pub type StreamingCommandFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
pub type StreamingCommandHandler =
Arc<dyn Fn(CommandContext, StreamSender) -> StreamingCommandFuture + Send + Sync>;
#[derive(Clone, Debug, PartialEq)]
pub struct CommandResult {
pub data: Value,
pub metadata: CommandResultMetadata,
}
impl CommandResult {
#[must_use]
pub fn new(data: Value) -> Self {
Self {
data,
metadata: CommandResultMetadata::default(),
}
}
#[must_use]
pub fn with_next_actions(mut self, actions: Vec<NextAction>) -> Self {
self.metadata.next_actions = actions;
self
}
}
impl From<Value> for CommandResult {
fn from(data: Value) -> Self {
Self::new(data)
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct CommandResultMetadata {
pub next_actions: Vec<NextAction>,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct CommandContext {
pub credential: CredentialResolver,
pub args: ValueMap,
pub user_args: ValueMap,
pub command_path: String,
pub middleware: Middleware,
pub raw_matches: Arc<ArgMatches>,
}
impl CommandContext {
#[must_use]
pub fn config(&self) -> &crate::config::ConfigFile {
&self.middleware.config
}
pub fn environment(&self) -> Result<crate::environments::Environment> {
let environments = self.middleware.environments.as_ref().ok_or_else(|| {
crate::error::CliCoreError::message("no environment system configured")
})?;
environments.resolve(&self.middleware.env)
}
pub fn typed_args<T: clap::FromArgMatches>(&self) -> Result<T> {
T::from_arg_matches(self.raw_matches.as_ref())
.map_err(|e| crate::CliCoreError::Message(format!("argument parse error: {e}")))
}
pub async fn credential(&self) -> Result<Credential> {
self.credential.resolve().await
}
pub async fn try_credential(&self) -> Result<Option<Credential>> {
self.credential.try_resolve().await
}
pub async fn credential_with_scopes(&self, extra: &[String]) -> Result<Credential> {
self.credential.resolve_with_scopes(extra).await
}
}
#[derive(Clone, Debug, Default)]
pub struct CommandSpec {
pub name: String,
pub short: String,
pub long: Option<String>,
pub aliases: Vec<String>,
pub hidden: bool,
pub system: Option<String>,
pub default_fields: Option<String>,
pub auth: AuthRequirement,
pub auth_provider: Option<String>,
pub tier: Option<Tier>,
pub mutates: bool,
pub auth_metadata: BTreeMap<String, String>,
pub args: Vec<Arg>,
pub output_schema: Option<SchemaInfo>,
pub view_columns: Vec<TableColumn>,
pub view_id: Option<String>,
}
impl CommandSpec {
#[must_use]
pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
Self {
name: name.into(),
short: short.into(),
..Self::default()
}
}
#[must_use]
pub fn from_args<T: clap::Args>(name: impl Into<String>, short: impl Into<String>) -> Self {
let placeholder = Command::new("__placeholder");
let augmented = T::augment_args(placeholder);
let args: Vec<Arg> = augmented
.get_arguments()
.filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
.cloned()
.collect();
Self {
name: name.into(),
short: short.into(),
args,
..Self::default()
}
}
#[must_use]
pub fn with_long(mut self, long: impl Into<String>) -> Self {
self.long = Some(long.into());
self
}
#[must_use]
pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
self.aliases.push(alias.into());
self
}
#[must_use]
pub fn hidden(mut self, hidden: bool) -> Self {
self.hidden = hidden;
self
}
#[must_use]
pub fn with_system(mut self, system: impl Into<String>) -> Self {
self.system = Some(system.into());
self
}
#[must_use]
pub fn with_default_fields(mut self, default_fields: impl Into<String>) -> Self {
self.default_fields = Some(default_fields.into());
self
}
#[must_use]
pub fn with_view(mut self, columns: impl Into<Vec<TableColumn>>) -> Self {
self.view_columns = columns.into();
self
}
#[must_use]
pub fn with_view_id(mut self, id: impl Into<String>) -> Self {
self.view_id = Some(id.into());
self
}
#[must_use]
pub fn with_auth_provider(mut self, provider: impl Into<String>) -> Self {
self.auth_provider = Some(provider.into());
self
}
#[must_use]
pub fn no_auth(mut self, no_auth: bool) -> Self {
self.auth = if no_auth {
AuthRequirement::None
} else {
AuthRequirement::Required
};
self
}
#[must_use]
pub fn auth(mut self, requirement: AuthRequirement) -> Self {
self.auth = requirement;
self
}
#[must_use]
pub fn auth_optional(mut self) -> Self {
self.auth = AuthRequirement::Optional;
self
}
#[must_use]
pub fn with_tier(mut self, tier: Tier) -> Self {
self.tier = Some(tier);
self
}
#[must_use]
pub fn with_auth_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.auth_metadata.insert(key.into(), value.into());
self
}
#[must_use]
pub fn with_scopes(mut self, scopes: &[impl AsRef<str>]) -> Self {
let joined = scopes
.iter()
.map(AsRef::as_ref)
.collect::<Vec<_>>()
.join(" ");
if joined.is_empty() {
self.auth_metadata.remove("scopes");
} else {
self.auth_metadata.insert("scopes".to_owned(), joined);
}
self
}
#[must_use]
pub fn with_arg(mut self, arg: Arg) -> Self {
self.args.push(arg);
self
}
#[must_use]
pub fn with_flag(self, flag: Arg) -> Self {
self.with_arg(flag)
}
#[must_use]
pub fn with_output_schema<T: OutputSchema>(mut self) -> Self {
self.output_schema = Some(SchemaInfo {
command: String::new(),
fields: crate::output::fields_for::<T>(),
schema: None,
});
self
}
#[must_use]
pub fn with_json_schema<T: JsonSchema>(mut self) -> Self {
self.output_schema = Some(crate::output::json_schema_info::<T>(""));
self
}
#[must_use]
pub fn mutates(mut self, mutates: bool) -> Self {
self.mutates = mutates;
self
}
#[must_use]
pub fn metadata(&self) -> CommandMeta {
let mut auth_metadata = self.auth_metadata.clone();
if let Some(provider) = &self.auth_provider
&& !provider.is_empty()
{
auth_metadata.insert("provider".to_owned(), provider.clone());
}
if let Some(tier) = self.tier
&& !auth_metadata.contains_key("tier")
{
auth_metadata.insert("tier".to_owned(), tier.to_string());
}
let scopes = auth_metadata
.get("scopes")
.map(|scopes| {
scopes
.split_whitespace()
.map(str::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
CommandMeta {
dry_run_prompt: self.mutates || self.tier.is_some_and(Tier::is_mutating),
auth_metadata,
scopes,
}
}
#[must_use]
pub fn clap_command(&self) -> Command {
let mut command = Command::new(self.name.clone()).about(self.short.clone());
if let Some(long) = &self.long
&& !long.is_empty()
{
command = command.long_about(long.clone());
}
for alias in &self.aliases {
command = command.alias(alias.clone());
}
if self.hidden {
command = command.hide(true);
}
for arg in &self.args {
command = command.arg(arg.clone());
}
command
}
}
#[derive(Clone, Debug, Default)]
pub struct GroupSpec {
pub name: String,
pub short: String,
pub long: Option<String>,
pub aliases: Vec<String>,
pub hidden: bool,
pub commands: Vec<CommandSpec>,
pub groups: Vec<GroupSpec>,
}
impl GroupSpec {
#[must_use]
pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
Self {
name: name.into(),
short: short.into(),
..Self::default()
}
}
#[must_use]
pub fn with_long(mut self, long: impl Into<String>) -> Self {
self.long = Some(long.into());
self
}
#[must_use]
pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
self.aliases.push(alias.into());
self
}
#[must_use]
pub fn hidden(mut self, hidden: bool) -> Self {
self.hidden = hidden;
self
}
#[must_use]
pub fn with_command(mut self, command: CommandSpec) -> Self {
self.commands.push(command);
self
}
#[must_use]
pub fn with_group(mut self, group: GroupSpec) -> Self {
self.groups.push(group);
self
}
#[must_use]
pub fn clap_command(&self) -> Command {
let mut command = Command::new(self.name.clone()).about(self.short.clone());
if let Some(long) = &self.long
&& !long.is_empty()
{
command = command.long_about(long.clone());
}
for alias in &self.aliases {
command = command.alias(alias.clone());
}
if self.hidden {
command = command.hide(true);
}
for group in &self.groups {
command = command.subcommand(group.clap_command());
}
for child in &self.commands {
command = command.subcommand(child.clap_command());
}
command
}
}
#[derive(Clone)]
pub struct RuntimeCommandSpec {
pub spec: CommandSpec,
pub handler: CommandHandler,
pub streaming_handler: Option<StreamingCommandHandler>,
}
impl std::fmt::Debug for RuntimeCommandSpec {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RuntimeCommandSpec")
.field("spec", &self.spec)
.field("is_streaming", &self.streaming_handler.is_some())
.finish_non_exhaustive()
}
}
impl RuntimeCommandSpec {
#[must_use]
pub fn new<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
where
F: Fn(CredentialResolver, ValueMap) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Output>> + Send + 'static,
Output: Into<CommandResult> + Send + 'static,
{
Self {
spec,
streaming_handler: None,
handler: Arc::new(move |context| {
let future = handler(context.credential, context.args);
Box::pin(async move { future.await.map(Into::into) })
}),
}
}
#[must_use]
pub fn new_with_context<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
where
F: Fn(CommandContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Output>> + Send + 'static,
Output: Into<CommandResult> + Send + 'static,
{
Self {
spec,
streaming_handler: None,
handler: Arc::new(move |context| {
let future = handler(context);
Box::pin(async move { future.await.map(Into::into) })
}),
}
}
#[must_use]
pub fn new_streaming<F, Fut>(spec: CommandSpec, handler: F) -> Self
where
F: Fn(CommandContext, StreamSender) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
let streaming: StreamingCommandHandler = Arc::new(move |context, sender| {
let future = handler(context, sender);
Box::pin(future)
});
Self {
spec,
streaming_handler: Some(streaming),
handler: Arc::new(|_context| Box::pin(async { Ok(CommandResult::new(Value::Null)) })),
}
}
#[must_use]
pub fn new_typed<T, F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
where
T: clap::FromArgMatches + Send + 'static,
F: Fn(CredentialResolver, T) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Output>> + Send + 'static,
Output: Into<CommandResult> + Send + 'static,
{
let handler = Arc::new(handler);
Self {
spec,
handler: Arc::new(move |context| {
let credential = context.credential.clone();
let parsed = T::from_arg_matches(context.raw_matches.as_ref());
let handler = handler.clone();
Box::pin(async move {
let args = parsed.map_err(|e| {
crate::CliCoreError::Message(format!("argument parse error: {e}"))
})?;
handler(credential, args).await.map(Into::into)
})
}),
streaming_handler: None,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct RuntimeGroupSpec {
pub group: GroupSpec,
pub commands: Vec<RuntimeCommandSpec>,
pub groups: Vec<RuntimeGroupSpec>,
}
impl RuntimeGroupSpec {
#[must_use]
pub fn new(group: GroupSpec) -> Self {
Self {
group,
..Self::default()
}
}
#[must_use]
pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
self.commands.push(command);
self
}
#[must_use]
pub fn with_group(mut self, group: RuntimeGroupSpec) -> Self {
self.groups.push(group);
self
}
#[must_use]
pub fn clap_command(&self) -> Command {
let mut command = Command::new(self.group.name.clone()).about(self.group.short.clone());
if let Some(long) = &self.group.long
&& !long.is_empty()
{
command = command.long_about(long.clone());
}
for alias in &self.group.aliases {
command = command.alias(alias.clone());
}
if self.group.hidden {
command = command.hide(true);
}
for group in &self.groups {
command = command.subcommand(group.clap_command());
}
for child in &self.commands {
command = command.subcommand(child.spec.clap_command());
}
command
}
pub(crate) fn register_commands(
&self,
prefix: &mut Vec<String>,
out: &mut BTreeMap<String, RuntimeCommandSpec>,
) {
prefix.push(self.group.name.clone());
for group in &self.groups {
group.register_commands(prefix, out);
}
for command in &self.commands {
prefix.push(command.spec.name.clone());
out.insert(prefix.join(":"), command.clone());
prefix.pop();
}
prefix.pop();
}
}
#[must_use]
pub fn command_path_from_matches(root_name: &str, matches: &ArgMatches) -> String {
let mut parts = Vec::new();
let mut current = matches;
while let Some((name, submatches)) = current.subcommand() {
if name != root_name {
parts.push(name.to_owned());
}
current = submatches;
}
parts.join(":")
}
#[must_use]
pub fn command_path_from_parts(parts: &[impl AsRef<str>], path_annotation: Option<&str>) -> String {
if parts.is_empty() {
return String::new();
}
if parts.len() > 1 {
return parts[1..]
.iter()
.map(AsRef::as_ref)
.collect::<Vec<_>>()
.join(":");
}
path_annotation
.filter(|annotation| !annotation.is_empty())
.map_or_else(|| parts[0].as_ref().to_owned(), ToOwned::to_owned)
}
#[must_use]
pub fn leaf_matches(matches: &ArgMatches) -> &ArgMatches {
let mut current = matches;
while let Some((_, submatches)) = current.subcommand() {
current = submatches;
}
current
}
#[must_use]
pub fn command_args_from_matches(
matches: &ArgMatches,
spec: &CommandSpec,
changed_only: bool,
) -> ValueMap {
let mut args = ValueMap::new();
for arg in &spec.args {
let id = arg.get_id().to_string();
let changed = matches
.value_source(&id)
.is_some_and(|source| source == clap::parser::ValueSource::CommandLine);
if changed_only && !changed {
continue;
}
if let Some(value) = arg_value_from_matches(matches, arg, &id) {
args.insert(id, value);
}
}
args
}
fn arg_value_from_matches(matches: &ArgMatches, flag: &Arg, id: &str) -> Option<Value> {
matches.value_source(id)?;
if matches!(flag.get_action(), ArgAction::SetTrue | ArgAction::SetFalse)
&& let Some(value) = matches.get_one::<bool>(id)
{
return Some(Value::Bool(*value));
}
if let Some(value) = typed_arg_value_from_matches(matches, id) {
return Some(value);
}
if let Some(values) = matches.get_raw(id) {
let rendered = values
.map(|value| value.to_string_lossy().into_owned())
.collect::<Vec<_>>();
return match rendered.as_slice() {
[] => None,
[single] => Some(Value::String(single.clone())),
_ => Some(Value::Array(
rendered.into_iter().map(Value::String).collect(),
)),
};
}
if let Some(value) = matches.get_one::<String>(id) {
return Some(Value::String(value.clone()));
}
if let Some(value) = matches.get_one::<usize>(id) {
return Some(serde_json::json!(value));
}
if let Some(value) = matches.get_one::<u64>(id) {
return Some(serde_json::json!(value));
}
if let Some(value) = matches.get_one::<i64>(id) {
return Some(serde_json::json!(value));
}
None
}
fn typed_arg_value_from_matches(matches: &ArgMatches, id: &str) -> Option<Value> {
typed_values::<bool>(matches, id, Value::Bool)
.or_else(|| typed_values::<i8>(matches, id, |value| Value::Number(value.into())))
.or_else(|| typed_values::<i16>(matches, id, |value| Value::Number(value.into())))
.or_else(|| typed_values::<i64>(matches, id, |value| Value::Number(value.into())))
.or_else(|| typed_values::<i32>(matches, id, |value| Value::Number(value.into())))
.or_else(|| typed_values::<u8>(matches, id, |value| Value::Number(value.into())))
.or_else(|| typed_values::<u16>(matches, id, |value| Value::Number(value.into())))
.or_else(|| typed_values::<u64>(matches, id, |value| Value::Number(value.into())))
.or_else(|| typed_values::<u32>(matches, id, |value| Value::Number(value.into())))
.or_else(|| {
typed_values::<usize>(matches, id, |value| {
u64::try_from(value).map_or(Value::Null, |value| Value::Number(value.into()))
})
})
.or_else(|| {
typed_values::<f64>(matches, id, |value| {
Number::from_f64(value).map_or(Value::Null, Value::Number)
})
})
.or_else(|| {
typed_values::<f32>(matches, id, |value| {
Number::from_f64(f64::from(value)).map_or(Value::Null, Value::Number)
})
})
.or_else(|| typed_values::<String>(matches, id, Value::String))
}
fn typed_values<T>(matches: &ArgMatches, id: &str, to_value: impl Fn(T) -> Value) -> Option<Value>
where
T: Clone + Send + Sync + 'static,
{
let Ok(Some(values)) = matches.try_get_many::<T>(id) else {
return None;
};
let values = values.cloned().map(to_value).collect::<Vec<_>>();
match values.as_slice() {
[] => None,
[single] => Some(single.clone()),
_ => Some(Value::Array(values)),
}
}