use std::sync::Arc;
pub use sen_rs_macros::handler;
pub use sen_rs_macros::sen;
pub use sen_rs_macros::SenRouter;
pub mod build_info;
pub mod tracing_support;
#[cfg(feature = "sensors")]
pub mod sensors;
#[cfg(feature = "mcp")]
pub mod mcp;
#[cfg(feature = "tracing")]
pub use tracing_support::tracing;
#[cfg(feature = "tracing")]
pub use tracing_support::{
debug, error, info, init_subscriber, init_subscriber_with_config, instrument, trace, warn,
TracingConfig, TracingFormat,
};
#[cfg(feature = "build-info")]
pub use build_info::{version_info, version_short};
#[cfg(feature = "sensors")]
pub use sensors::{GitSensor, SensorData, Sensors};
#[cfg(feature = "clap")]
pub use clap;
type CommandEntry = (String, String, String);
type GroupedCommands = Vec<(String, Vec<CommandEntry>)>;
pub struct State<T>(Arc<tokio::sync::RwLock<T>>);
impl<T> Clone for State<T> {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
impl<T> State<T> {
pub fn new(inner: T) -> Self {
Self(Arc::new(tokio::sync::RwLock::new(inner)))
}
pub async fn read(&self) -> tokio::sync::RwLockReadGuard<'_, T> {
self.0.read().await
}
pub async fn write(&self) -> tokio::sync::RwLockWriteGuard<'_, T> {
self.0.write().await
}
}
pub struct GlobalOptions<T>(Arc<T>);
impl<T> Clone for GlobalOptions<T> {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
impl<T> GlobalOptions<T> {
pub fn new(inner: T) -> Self {
Self(Arc::new(inner))
}
pub fn get(&self) -> &T {
&self.0
}
}
pub type CliResult<T> = Result<T, CliError>;
#[derive(Debug, thiserror::Error)]
pub enum CliError {
#[error(transparent)]
User(#[from] UserError),
#[error(transparent)]
System(#[from] SystemError),
}
impl CliError {
pub fn exit_code(&self) -> i32 {
match self {
CliError::User(user_err) => match user_err {
UserError::Help(_) => 0, _ => 1,
},
CliError::System(_) => 101,
}
}
pub fn user(message: impl Into<String>) -> Self {
CliError::User(UserError::Generic(message.into()))
}
pub fn system(message: impl Into<String>) -> Self {
CliError::System(SystemError::Internal(message.into()))
}
}
#[derive(Debug, thiserror::Error)]
pub enum UserError {
#[error("Error: {0}")]
Generic(String),
#[error("{0}")]
Help(String),
#[error("Error: Invalid argument '{arg}'\n\n{reason}")]
InvalidArgument { arg: String, reason: String },
#[error("Error: Missing dependency '{tool}'\n\nHint: {install_hint}")]
MissingDependency { tool: String, install_hint: String },
#[error("Error: Validation failed\n\n{}", .details.join("\n"))]
ValidationFailed { details: Vec<String> },
#[error("Error: Prerequisite not met: {check}\n\nHint: {fix_hint}")]
PrerequisiteNotMet { check: String, fix_hint: String },
}
#[derive(Debug, thiserror::Error)]
pub enum SystemError {
#[error("Internal Error: {0}\n\nThis is likely a bug.")]
Internal(String),
#[error("Internal Error: I/O operation failed\n\n{0:?}\n\nThis is likely a bug.")]
Io(#[from] std::io::Error),
#[error("Internal Error: Config parse failed\n\n{0}\n\nThis is likely a bug.")]
ConfigParse(String),
}
pub struct Response {
pub exit_code: i32,
pub output: Output,
pub agent_mode: bool,
#[cfg(feature = "sensors")]
pub metadata: Option<ResponseMetadata>,
}
#[cfg(feature = "sensors")]
#[derive(Debug, Clone, serde::Serialize)]
pub struct ResponseMetadata {
pub tier: Option<&'static str>,
pub tags: Option<Vec<&'static str>>,
pub sensors: Option<crate::sensors::SensorData>,
}
impl Response {
pub fn text(content: impl Into<String>) -> Self {
Self {
exit_code: 0,
output: Output::Text(content.into()),
agent_mode: false,
#[cfg(feature = "sensors")]
metadata: None,
}
}
pub fn silent() -> Self {
Self {
exit_code: 0,
output: Output::Silent,
agent_mode: false,
#[cfg(feature = "sensors")]
metadata: None,
}
}
pub fn error(exit_code: i32, message: impl Into<String>) -> Self {
Self {
exit_code,
output: Output::Text(message.into()),
agent_mode: false,
#[cfg(feature = "sensors")]
metadata: None,
}
}
#[cfg(feature = "sensors")]
pub fn with_metadata(mut self, metadata: ResponseMetadata) -> Self {
self.metadata = Some(metadata);
self
}
#[cfg(feature = "sensors")]
pub fn to_agent_json(&self) -> String {
let result = if self.exit_code == 0 {
"success"
} else {
"error"
};
let output = match &self.output {
Output::Silent => String::new(),
Output::Text(s) => s.clone(),
Output::Json(s) => s.clone(),
};
let mut json = serde_json::json!({
"result": result,
"exit_code": self.exit_code,
"output": output,
});
if let Some(ref metadata) = self.metadata {
if let Some(tier) = metadata.tier {
json["tier"] = serde_json::json!(tier);
}
if let Some(ref tags) = metadata.tags {
json["tags"] = serde_json::json!(tags);
}
if let Some(ref sensors) = metadata.sensors {
json["sensors"] = serde_json::to_value(sensors).unwrap_or(serde_json::json!(null));
}
}
serde_json::to_string_pretty(&json).unwrap_or_else(|_| "{}".to_string())
}
}
#[derive(Debug)]
pub enum Output {
Silent,
Text(String),
Json(String),
}
impl Output {
pub fn is_empty(&self) -> bool {
matches!(self, Output::Silent)
}
}
impl std::fmt::Display for Output {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Output::Silent => Ok(()),
Output::Text(s) | Output::Json(s) => write!(f, "{}", s),
}
}
}
pub trait IntoResponse {
fn into_response(self) -> Response;
}
impl IntoResponse for String {
fn into_response(self) -> Response {
Response::text(self)
}
}
impl IntoResponse for () {
fn into_response(self) -> Response {
Response::silent()
}
}
impl<T: IntoResponse> IntoResponse for CliResult<T> {
fn into_response(self) -> Response {
match self {
Ok(value) => value.into_response(),
Err(e) => {
let exit_code = e.exit_code();
let message = match &e {
CliError::User(UserError::Help(help_text)) => {
return Response {
output: Output::Text(help_text.clone()),
exit_code: 0,
agent_mode: false,
metadata: None,
};
}
CliError::User(user_err) => format!("{}", user_err),
CliError::System(sys_err) => format!("{}", sys_err),
};
Response::error(exit_code, message)
}
}
}
}
use std::collections::HashMap;
use std::future::Future;
use std::marker::PhantomData;
use std::pin::Pin;
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
pub enum Tier {
Safe,
Standard,
Critical,
}
impl Tier {
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"safe" => Some(Tier::Safe),
"standard" => Some(Tier::Standard),
"critical" => Some(Tier::Critical),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Tier::Safe => "safe",
Tier::Standard => "standard",
Tier::Critical => "critical",
}
}
pub fn requires_approval(&self) -> bool {
matches!(self, Tier::Critical)
}
}
impl std::fmt::Display for Tier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[cfg(feature = "clap")]
impl std::str::FromStr for Tier {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s).ok_or_else(|| {
format!(
"Invalid tier: '{}'. Valid options: safe, standard, critical",
s
)
})
}
}
#[derive(Debug, Clone)]
pub struct RouterMetadata {
pub name: &'static str,
pub version: Option<&'static str>,
pub about: Option<&'static str>,
}
#[derive(Debug, Clone)]
pub struct HandlerMetadata {
pub desc: Option<&'static str>,
pub tier: Option<Tier>,
pub tags: Option<Vec<&'static str>>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct RouteMetadata {
handler_meta: Option<HandlerMetadata>,
description: Option<String>,
args_schema: Option<serde_json::Value>,
}
impl RouteMetadata {
pub fn get_description(&self) -> Option<&str> {
self.description
.as_deref()
.or_else(|| self.handler_meta.as_ref()?.desc)
}
pub fn get_args_schema(&self) -> Option<&serde_json::Value> {
self.args_schema.as_ref()
}
}
pub trait Handler<T, S>: Clone + Send + Sync + Sized + 'static {
type Future: Future<Output = Response> + Send + 'static;
fn call(self, state: State<S>, args: Vec<String>) -> Self::Future;
fn metadata(&self) -> Option<HandlerMetadata> {
None
}
fn args_schema(&self) -> Option<serde_json::Value> {
None
}
}
pub struct HandlerWithMeta<H, T, S> {
pub handler: H,
pub metadata: HandlerMetadata,
_marker: PhantomData<fn() -> (T, S)>,
}
impl<H, T, S> Clone for HandlerWithMeta<H, T, S>
where
H: Clone,
{
fn clone(&self) -> Self {
Self {
handler: self.handler.clone(),
metadata: self.metadata.clone(),
_marker: PhantomData,
}
}
}
impl<H, T, S> HandlerWithMeta<H, T, S>
where
H: Handler<T, S>,
{
pub fn new(handler: H, metadata: HandlerMetadata) -> Self {
Self {
handler,
metadata,
_marker: PhantomData,
}
}
}
impl<H, T, S> Handler<T, S> for HandlerWithMeta<H, T, S>
where
H: Handler<T, S>,
T: 'static,
S: Send + Sync + Clone + 'static,
{
type Future = H::Future;
fn call(self, state: State<S>, args: Vec<String>) -> Self::Future {
self.handler.call(state, args)
}
fn metadata(&self) -> Option<HandlerMetadata> {
Some(self.metadata.clone())
}
fn args_schema(&self) -> Option<serde_json::Value> {
self.handler.args_schema()
}
}
trait ErasedHandler<S>: Send + Sync {
fn call_boxed<'a>(&'a self, state: State<S>, args: Vec<String>) -> BoxFuture<'a, Response>;
fn clone_box(&self) -> Box<dyn ErasedHandler<S>>;
#[allow(dead_code)]
fn metadata(&self) -> Option<HandlerMetadata>;
#[allow(dead_code)]
fn args_schema(&self) -> Option<serde_json::Value>;
}
impl<S> Clone for Box<dyn ErasedHandler<S>> {
fn clone(&self) -> Self {
self.clone_box()
}
}
struct HandlerService<H, T, S> {
handler: H,
_marker: PhantomData<fn() -> (T, S)>,
}
impl<H, T, S> HandlerService<H, T, S> {
fn new(handler: H) -> Self {
Self {
handler,
_marker: PhantomData,
}
}
}
impl<H, T, S> Clone for HandlerService<H, T, S>
where
H: Clone,
{
fn clone(&self) -> Self {
Self {
handler: self.handler.clone(),
_marker: PhantomData,
}
}
}
impl<H, T, S> ErasedHandler<S> for HandlerService<H, T, S>
where
H: Handler<T, S>,
S: Send + Sync + 'static,
T: 'static,
{
fn call_boxed<'a>(&'a self, state: State<S>, args: Vec<String>) -> BoxFuture<'a, Response> {
let handler = self.handler.clone();
Box::pin(async move { handler.call(state, args).await })
}
fn clone_box(&self) -> Box<dyn ErasedHandler<S>> {
Box::new(self.clone())
}
fn metadata(&self) -> Option<HandlerMetadata> {
self.handler.metadata()
}
fn args_schema(&self) -> Option<serde_json::Value> {
self.handler.args_schema()
}
}
pub struct Router<S = ()> {
routes: HashMap<String, Box<dyn ErasedHandler<S>>>,
route_metadata: HashMap<String, RouteMetadata>,
metadata: Option<RouterMetadata>,
agent_mode_enabled: bool,
#[cfg(feature = "mcp")]
mcp_enabled: bool,
_marker: PhantomData<S>,
}
impl<S> Default for Router<S>
where
S: Send + Sync + Clone + 'static,
{
fn default() -> Self {
Self::new()
}
}
impl<S> Router<S>
where
S: Send + Sync + Clone + 'static,
{
pub fn new() -> Self {
Self {
routes: HashMap::new(),
route_metadata: HashMap::new(),
metadata: None,
agent_mode_enabled: false,
#[cfg(feature = "mcp")]
mcp_enabled: false,
_marker: PhantomData,
}
}
pub fn route<H, T: 'static>(mut self, command: impl Into<String>, handler: H) -> Self
where
H: Handler<T, S>,
{
let command_name = command.into();
if self.routes.contains_key(&command_name) {
panic!("Duplicate route: {}", command_name);
}
let handler_meta = handler.metadata();
let args_schema = handler.args_schema();
self.route_metadata.insert(
command_name.clone(),
RouteMetadata {
handler_meta,
description: None,
args_schema,
},
);
self.routes
.insert(command_name, Box::new(HandlerService::new(handler)));
self
}
pub fn nest(mut self, prefix: impl Into<String>, router: Router<S>) -> Self {
let prefix = prefix.into();
for (path, handler) in router.routes {
let nested_path = if path.is_empty() {
prefix.clone()
} else {
format!("{}:{}", prefix, path)
};
if self.routes.contains_key(&nested_path) {
panic!("Duplicate route: {}", nested_path);
}
self.routes.insert(nested_path.clone(), handler);
if let Some(meta) = router.route_metadata.get(&path) {
self.route_metadata.insert(nested_path, meta.clone());
}
}
self
}
pub fn with_metadata(mut self, metadata: RouterMetadata) -> Self {
self.metadata = Some(metadata);
self
}
pub fn with_agent_mode(mut self) -> Self {
self.agent_mode_enabled = true;
self
}
#[cfg(feature = "mcp")]
pub fn with_mcp(mut self) -> Self {
self.mcp_enabled = true;
self
}
pub fn with_state(self, state: S) -> Router<()> {
let routes: HashMap<String, Box<dyn ErasedHandler<()>>> = self
.routes
.into_iter()
.map(|(cmd, handler)| {
let state = state.clone();
let boxed: Box<dyn ErasedHandler<()>> =
Box::new(StatefulHandler { handler, state });
(cmd, boxed)
})
.collect();
Router {
routes,
route_metadata: self.route_metadata,
metadata: self.metadata,
agent_mode_enabled: self.agent_mode_enabled,
#[cfg(feature = "mcp")]
mcp_enabled: self.mcp_enabled,
_marker: PhantomData,
}
}
}
struct StatefulHandler<S> {
handler: Box<dyn ErasedHandler<S>>,
state: S,
}
impl<S> Clone for StatefulHandler<S>
where
S: Clone,
{
fn clone(&self) -> Self {
Self {
handler: self.handler.clone(),
state: self.state.clone(),
}
}
}
impl<S> ErasedHandler<()> for StatefulHandler<S>
where
S: Clone + Send + Sync + 'static,
{
fn call_boxed<'a>(&'a self, _state: State<()>, args: Vec<String>) -> BoxFuture<'a, Response> {
let handler = self.handler.clone();
let state = State::new(self.state.clone());
Box::pin(async move { handler.call_boxed(state, args).await })
}
fn clone_box(&self) -> Box<dyn ErasedHandler<()>> {
Box::new(self.clone())
}
fn metadata(&self) -> Option<HandlerMetadata> {
self.handler.metadata()
}
fn args_schema(&self) -> Option<serde_json::Value> {
self.handler.args_schema()
}
}
impl Router<()> {
pub async fn execute(&self) -> Response {
let args: Vec<String> = std::env::args().collect();
self.execute_with(&args).await
}
pub async fn execute_with(&self, args: &[String]) -> Response {
let command_args = if args.is_empty() { &[] } else { &args[1..] };
let (agent_mode_active, command_args) = if self.agent_mode_enabled {
let agent_mode = command_args.contains(&"--agent-mode".to_string());
let filtered: Vec<String> = command_args
.iter()
.filter(|arg| *arg != "--agent-mode")
.cloned()
.collect();
(agent_mode, filtered)
} else {
(false, command_args.to_vec())
};
let command_args_slice: &[String] = &command_args;
#[cfg(feature = "mcp")]
if self.mcp_enabled {
if command_args_slice.contains(&"--mcp-server".to_string()) {
let tools: Vec<crate::mcp::McpTool> = self
.route_metadata
.iter()
.map(|(name, metadata)| {
crate::mcp::McpTool::from_route_metadata(name.clone(), metadata)
})
.collect();
let program_name = args
.get(0)
.cloned()
.unwrap_or_else(|| "program".to_string());
return crate::mcp::run_mcp_server(tools, |tool_name, tool_args| {
let mut full_args = vec![program_name.clone()];
for part in tool_name.split(':') {
full_args.push(part.to_string());
}
full_args.extend(tool_args);
futures::executor::block_on(self.execute_with(&full_args))
});
}
if let Some(pos) = command_args_slice
.iter()
.position(|arg| arg == "--mcp-init")
{
let client = command_args_slice
.get(pos + 1)
.map(|s| s.as_str())
.unwrap_or("claude");
let command_path = args
.get(0)
.cloned()
.or_else(|| {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(String::from))
})
.unwrap_or_else(|| "myctl".to_string());
let tools: Vec<crate::mcp::McpTool> = self
.route_metadata
.iter()
.map(|(name, metadata)| {
crate::mcp::McpTool::from_route_metadata(name.clone(), metadata)
})
.collect();
return crate::mcp::generate_mcp_config(client, command_path, tools);
}
}
if command_args_slice.is_empty()
|| (command_args_slice.len() == 1
&& (command_args_slice[0] == "--help" || command_args_slice[0] == "-h"))
{
let mut response = self.generate_help(&[], false);
response.agent_mode = agent_mode_active;
return response;
}
if command_args_slice.contains(&"--help".to_string())
&& command_args_slice.contains(&"--json".to_string())
{
let mut response = self.generate_cli_schema_json();
response.agent_mode = agent_mode_active;
return response;
}
if command_args_slice.contains(&"--help".to_string())
&& command_args_slice.contains(&"--md".to_string())
{
let mut response = self.generate_help_markdown();
response.agent_mode = agent_mode_active;
return response;
}
if command_args_slice.len() == 1
&& (command_args_slice[0] == "version"
|| command_args_slice[0] == "--version"
|| command_args_slice[0] == "-V")
{
let mut response = self.handle_version();
response.agent_mode = agent_mode_active;
return response;
}
let (matched_handler, remaining_args) = self.find_route(command_args_slice);
let mut response = match matched_handler {
Some(handler) => {
let state = State::new(());
handler.call_boxed(state, remaining_args).await
}
None => {
let command = command_args_slice.join(" ");
let err: CliResult<()> =
Err(CliError::user(format!("Unknown command: {}", command)));
err.into_response()
}
};
response.agent_mode = agent_mode_active;
response
}
fn generate_help(&self, _args: &[String], json_output: bool) -> Response {
if json_output {
self.generate_cli_schema_json()
} else {
self.generate_help_text()
}
}
fn generate_help_text(&self) -> Response {
use anstyle::{AnsiColor, Effects, Style};
let use_color = std::io::IsTerminal::is_terminal(&std::io::stdout());
let header_style = if use_color {
Style::new()
.fg_color(Some(AnsiColor::Green.into()))
.effects(Effects::BOLD)
} else {
Style::new()
};
let section_style = if use_color {
Style::new()
.fg_color(Some(AnsiColor::Yellow.into()))
.effects(Effects::BOLD)
} else {
Style::new()
};
let cmd_style = if use_color {
Style::new().fg_color(Some(AnsiColor::Cyan.into()))
} else {
Style::new()
};
let dim_style = if use_color {
Style::new().effects(Effects::DIMMED)
} else {
Style::new()
};
let reset = if use_color {
Style::new().render_reset().to_string()
} else {
String::new()
};
let mut help = String::new();
if let Some(meta) = &self.metadata {
help.push_str(&format!("{}", header_style.render()));
help.push_str(meta.name);
if let Some(version) = meta.version {
help.push_str(&format!(" {}", version));
}
help.push_str(&reset);
help.push('\n');
if let Some(about) = meta.about {
help.push_str(about);
help.push('\n');
}
help.push('\n');
}
let cli_name = self
.metadata
.as_ref()
.map(|m| m.name)
.unwrap_or("<command>");
help.push_str(&format!(
"{}Usage:{} {} [OPTIONS] <COMMAND>\n\n",
section_style.render(),
reset,
cli_name
));
let grouped_commands = self.group_commands_by_prefix();
let global_max_len = grouped_commands
.iter()
.flat_map(|(_, cmds)| cmds.iter().map(|(name, _, _)| name.len()))
.max()
.unwrap_or(8)
.max(8);
for (group_name, commands) in &grouped_commands {
if !group_name.is_empty() {
help.push_str(&format!(
"{}{}:{}\n",
section_style.render(),
group_name,
reset
));
} else {
help.push_str(&format!("{}Commands:{}\n", section_style.render(), reset));
}
for (name, _full_name, desc) in commands {
if desc.is_empty() {
help.push_str(&format!(" {}{}{}\n", cmd_style.render(), name, reset));
} else {
help.push_str(&format!(
" {}{:width$}{} {}{}{}\n",
cmd_style.render(),
name,
reset,
dim_style.render(),
desc,
reset,
width = global_max_len
));
}
}
help.push('\n');
}
help.push_str(&format!("{}Options:{}\n", section_style.render(), reset));
help.push_str(&format!(
" {}-h{}, {}--help{} Print help\n",
cmd_style.render(),
reset,
cmd_style.render(),
reset
));
if self.metadata.as_ref().and_then(|m| m.version).is_some() {
help.push_str(&format!(
" {}-V{}, {}--version{} Print version\n",
cmd_style.render(),
reset,
cmd_style.render(),
reset
));
}
help.push('\n');
help.push_str(&format!(
"{}For AI/Agent:{}\n",
section_style.render(),
reset
));
help.push_str(&format!(
" {}--help --md{} Print help in Markdown format\n",
cmd_style.render(),
reset
));
help.push('\n');
help.push_str(&format!(
"{}For Programs:{}\n",
section_style.render(),
reset
));
help.push_str(&format!(
" {}--help --json{} Print CLI schema in JSON format\n",
cmd_style.render(),
reset
));
Response::text(help)
}
fn generate_help_markdown(&self) -> Response {
let mut md = String::new();
let cli_name = self.metadata.as_ref().map(|m| m.name).unwrap_or("CLI");
let version = self
.metadata
.as_ref()
.and_then(|m| m.version)
.unwrap_or("0.0.0");
let about = self
.metadata
.as_ref()
.and_then(|m| m.about)
.unwrap_or("Command-line interface");
md.push_str(&format!("# {} v{}\n\n", cli_name, version));
md.push_str(&format!("{}\n\n", about));
md.push_str("## Usage\n\n");
md.push_str(&format!("```\n{} [OPTIONS] <COMMAND>\n```\n\n", cli_name));
md.push_str("## Commands\n\n");
let grouped_commands = self.group_commands_by_prefix();
for (group_name, commands) in &grouped_commands {
if !group_name.is_empty() && group_name != "Other Commands" {
md.push_str(&format!("### {}\n\n", group_name));
} else if group_name == "Other Commands" {
md.push_str("### Other Commands\n\n");
}
md.push_str("| Command | Description | Tier | Tags |\n");
md.push_str("|---------|-------------|------|------|\n");
for (_name, full_name, desc) in commands {
let meta = self.route_metadata.get(full_name.as_str());
let handler_meta = meta.and_then(|m| m.handler_meta.as_ref());
let tier = handler_meta
.and_then(|h| h.tier)
.map(|t| t.as_str())
.unwrap_or("-");
let tags = handler_meta
.and_then(|h| h.tags.as_ref())
.map(|t| t.join(", "))
.unwrap_or_else(|| "-".to_string());
let desc_escaped = if desc.is_empty() {
"-".to_string()
} else {
desc.replace('|', "\\|")
};
md.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
full_name, desc_escaped, tier, tags
));
}
md.push('\n');
}
md.push_str("## Options\n\n");
md.push_str("| Option | Description |\n");
md.push_str("|--------|-------------|\n");
md.push_str("| `-h, --help` | Print help |\n");
md.push_str("| `--help --md` | Print help (Markdown format) |\n");
md.push_str("| `--help --json` | Print CLI schema (JSON format) |\n");
if self.metadata.as_ref().and_then(|m| m.version).is_some() {
md.push_str("| `-V, --version` | Print version |\n");
}
md.push('\n');
md.push_str("## Command Details\n\n");
let mut sorted_commands: Vec<_> = self.routes.keys().collect();
sorted_commands.sort();
for cmd in sorted_commands {
let meta = self.route_metadata.get(cmd.as_str());
let handler_meta = meta.and_then(|m| m.handler_meta.as_ref());
let desc = handler_meta.and_then(|h| h.desc).unwrap_or("");
md.push_str(&format!("### `{}`\n\n", cmd));
if !desc.is_empty() {
md.push_str(&format!("{}\n\n", desc));
}
md.push_str(&format!(
"```\n{} {}\n```\n\n",
cli_name,
cmd.replace(':', " ")
));
}
Response::text(md)
}
fn group_commands_by_prefix(&self) -> GroupedCommands {
use std::collections::HashMap;
let mut groups: HashMap<String, Vec<CommandEntry>> = HashMap::new();
let mut commands: Vec<_> = self.routes.keys().collect();
commands.sort();
for cmd in commands {
let desc = self
.route_metadata
.get(cmd.as_str())
.and_then(|meta| meta.get_description())
.unwrap_or("");
if let Some(colon_pos) = cmd.find(':') {
let prefix = &cmd[..colon_pos];
let suffix = &cmd[colon_pos + 1..];
let group_name = self.format_group_name(prefix);
groups.entry(group_name).or_default().push((
suffix.to_string(),
cmd.to_string(),
desc.to_string(),
));
} else {
groups
.entry("Other Commands".to_string())
.or_default()
.push((cmd.to_string(), cmd.to_string(), desc.to_string()));
}
}
for (_, commands) in groups.iter_mut() {
commands.sort_by(|a, b| a.0.cmp(&b.0));
}
let mut result: Vec<_> = groups.into_iter().collect();
result.sort_by(|a, b| {
if a.0 == "Other Commands" {
std::cmp::Ordering::Greater
} else if b.0 == "Other Commands" {
std::cmp::Ordering::Less
} else {
a.0.cmp(&b.0)
}
});
result
}
fn format_group_name(&self, prefix: &str) -> String {
let capitalized = match prefix {
"db" => "Database",
"config" => "Configuration",
"deploy" => "Deployment",
"server" => "Server",
"network" => "Network",
"storage" => "Storage",
_ => {
let mut chars = prefix.chars();
match chars.next() {
None => return "Commands".to_string(),
Some(first) => {
return format!(
"{}{} Commands",
first.to_uppercase(),
chars.collect::<String>()
);
}
}
}
};
format!("{} Commands", capitalized)
}
fn generate_cli_schema_json(&self) -> Response {
use serde_json::json;
let name = self.metadata.as_ref().map(|m| m.name).unwrap_or("cli");
let version = self
.metadata
.as_ref()
.and_then(|m| m.version)
.unwrap_or("unknown");
let description = self.metadata.as_ref().and_then(|m| m.about);
let mut commands = serde_json::Map::new();
let mut command_names: Vec<_> = self.routes.keys().collect();
command_names.sort();
for cmd in command_names {
let handler_meta = self
.route_metadata
.get(cmd)
.and_then(|meta| meta.handler_meta.as_ref());
let desc = handler_meta
.and_then(|h| h.desc)
.unwrap_or("No description available");
let tier = handler_meta.and_then(|h| h.tier).map(|t| t.as_str());
let tags = handler_meta.and_then(|h| h.tags.as_ref());
let usage = format!("{} {}", name, cmd.replace(':', " "));
let mut command_schema = json!({
"description": desc,
"usage": usage,
});
if let Some(tier_str) = tier {
command_schema["tier"] = json!(tier_str);
command_schema["requires_approval"] = json!(Tier::parse(tier_str)
.map(|t| t.requires_approval())
.unwrap_or(false));
}
if let Some(tag_list) = tags {
command_schema["tags"] = json!(tag_list);
}
if let Some(meta) = self.route_metadata.get(cmd) {
if let Some(args_schema) = &meta.args_schema {
command_schema["arguments"] = args_schema["arguments"].clone();
command_schema["options"] = args_schema["options"].clone();
}
}
commands.insert(cmd.to_string(), command_schema);
}
let spec = json!({
"name": name,
"version": version,
"description": description.unwrap_or(""),
"commands": commands,
});
match serde_json::to_string_pretty(&spec) {
Ok(json) => Response::text(json),
Err(e) => Response::error(1, format!("Failed to generate JSON: {}", e)),
}
}
fn handle_version(&self) -> Response {
if let Some(meta) = &self.metadata {
if let Some(version) = meta.version {
return Response::text(format!("{} {}", meta.name, version));
}
}
#[cfg(feature = "build-info")]
{
Response::text(crate::version_info())
}
#[cfg(not(feature = "build-info"))]
Response::text("version information not available")
}
fn find_route(&self, args: &[String]) -> (Option<&dyn ErasedHandler<()>>, Vec<String>) {
for depth in (1..=args.len()).rev() {
let route_parts = &args[..depth];
let route_key = route_parts.join(":");
if let Some(handler) = self.routes.get(&route_key) {
let remaining = args[depth..].to_vec();
return (Some(handler.as_ref()), remaining);
}
}
(None, args.to_vec())
}
}
#[derive(Debug, Clone)]
pub struct Args<T>(pub T);
pub trait FromArgs: Sized {
fn from_args(args: &[String]) -> Result<Self, CliError>;
fn cli_schema() -> Option<serde_json::Value> {
None
}
}
pub trait FromGlobalArgs: Sized + Clone {
fn from_global_args(args: &[String]) -> Result<(Self, Vec<String>), CliError>;
}
#[cfg(feature = "clap")]
impl<T> FromArgs for T
where
T: clap::Parser,
{
fn from_args(args: &[String]) -> Result<Self, CliError> {
let args_with_cmd = std::iter::once("cmd".to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
T::try_parse_from(args_with_cmd).map_err(|e| {
use clap::error::ErrorKind;
match e.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
CliError::User(UserError::Help(e.to_string()))
}
_ => CliError::user(e.to_string()),
}
})
}
fn cli_schema() -> Option<serde_json::Value> {
let cmd = T::command();
Some(clap_command_to_json(&cmd))
}
}
#[cfg(feature = "clap")]
fn clap_command_to_json(cmd: &clap::Command) -> serde_json::Value {
use serde_json::json;
let mut positionals = Vec::new();
let mut options = Vec::new();
for arg in cmd.get_arguments() {
if arg.is_positional() {
positionals.push(json!({
"name": arg.get_id().as_str(),
"type": format!("{:?}", arg.get_value_parser().type_id()),
"required": arg.is_required_set(),
"description": arg.get_help().map(|h| h.to_string()).unwrap_or_default(),
}));
} else {
let mut option = json!({
"name": format!("--{}", arg.get_id().as_str()),
"type": format!("{:?}", arg.get_value_parser().type_id()),
"required": arg.is_required_set(),
"description": arg.get_help().map(|h| h.to_string()).unwrap_or_default(),
});
if let Some(short) = arg.get_short() {
option["short"] = json!(format!("-{}", short));
}
let defaults = arg.get_default_values();
if !defaults.is_empty() {
option["default"] = json!(defaults[0].to_string_lossy().to_string());
}
if let Some(env) = arg.get_env() {
option["env"] = json!(env.to_string_lossy().to_string());
}
options.push(option);
}
}
json!({
"arguments": positionals,
"options": options,
})
}
#[cfg(feature = "clap")]
impl<T> FromGlobalArgs for T
where
T: clap::Parser + Clone,
{
fn from_global_args(args: &[String]) -> Result<(Self, Vec<String>), CliError> {
let args_with_cmd = std::iter::once("cmd".to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
match T::try_parse_from(&args_with_cmd) {
Ok(global) => {
Ok((global, vec![]))
}
Err(e) => Err(CliError::user(e.to_string())),
}
}
}
#[cfg(not(feature = "clap"))]
impl FromArgs for () {
fn from_args(_args: &[String]) -> Result<Self, CliError> {
Ok(())
}
}
#[cfg(not(feature = "clap"))]
impl FromArgs for Vec<String> {
fn from_args(args: &[String]) -> Result<Self, CliError> {
Ok(args.to_vec())
}
}
impl<F, Fut, S, Res> Handler<(State<S>,), S> for F
where
F: Fn(State<S>) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = Res> + Send + 'static,
Res: IntoResponse + 'static,
S: Send + Sync + Clone + 'static,
{
type Future = Pin<Box<dyn Future<Output = Response> + Send>>;
fn call(self, state: State<S>, _args: Vec<String>) -> Self::Future {
Box::pin(async move {
let result = self(state).await;
result.into_response()
})
}
}
impl<F, Fut, S, T, Res> Handler<(State<S>, Args<T>), S> for F
where
F: Fn(State<S>, Args<T>) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = Res> + Send + 'static,
Res: IntoResponse + 'static,
T: FromArgs + Send + 'static,
S: Send + Sync + Clone + 'static,
{
type Future = Pin<Box<dyn Future<Output = Response> + Send>>;
fn call(self, state: State<S>, args: Vec<String>) -> Self::Future {
Box::pin(async move {
let parsed_args = match T::from_args(&args) {
Ok(args) => args,
Err(e) => {
let result: CliResult<()> = Err(e);
return result.into_response();
}
};
let result = self(state, Args(parsed_args)).await;
result.into_response()
})
}
fn args_schema(&self) -> Option<serde_json::Value> {
T::cli_schema()
}
}
impl<F, Fut, T, Res> Handler<(Args<T>,), ()> for F
where
F: Fn(Args<T>) -> Fut + Clone + Send + Sync + 'static,
Fut: Future<Output = Res> + Send + 'static,
Res: IntoResponse + 'static,
T: FromArgs + Send + 'static,
{
type Future = Pin<Box<dyn Future<Output = Response> + Send>>;
fn call(self, _state: State<()>, args: Vec<String>) -> Self::Future {
Box::pin(async move {
let parsed_args = match T::from_args(&args) {
Ok(args) => args,
Err(e) => {
let result: CliResult<()> = Err(e);
return result.into_response();
}
};
let result = self(Args(parsed_args)).await;
result.into_response()
})
}
fn args_schema(&self) -> Option<serde_json::Value> {
T::cli_schema()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tier_parse() {
assert_eq!(Tier::parse("safe"), Some(Tier::Safe));
assert_eq!(Tier::parse("SAFE"), Some(Tier::Safe));
assert_eq!(Tier::parse("standard"), Some(Tier::Standard));
assert_eq!(Tier::parse("critical"), Some(Tier::Critical));
assert_eq!(Tier::parse("invalid"), None);
}
#[test]
fn test_tier_as_str() {
assert_eq!(Tier::Safe.as_str(), "safe");
assert_eq!(Tier::Standard.as_str(), "standard");
assert_eq!(Tier::Critical.as_str(), "critical");
}
#[test]
fn test_tier_requires_approval() {
assert!(!Tier::Safe.requires_approval());
assert!(!Tier::Standard.requires_approval());
assert!(Tier::Critical.requires_approval());
}
#[test]
fn test_tier_display() {
assert_eq!(format!("{}", Tier::Safe), "safe");
assert_eq!(format!("{}", Tier::Standard), "standard");
assert_eq!(format!("{}", Tier::Critical), "critical");
}
#[tokio::test]
async fn test_state_creation_and_access() {
struct TestState {
value: i32,
}
let state = State::new(TestState { value: 42 });
assert_eq!(state.read().await.value, 42);
let cloned = state.clone();
assert_eq!(cloned.read().await.value, 42);
}
#[tokio::test]
async fn test_state_write() {
struct TestState {
value: i32,
}
let state = State::new(TestState { value: 42 });
{
let mut app = state.write().await;
app.value = 100;
}
assert_eq!(state.read().await.value, 100);
}
#[test]
fn test_user_error_exit_code() {
let err = CliError::user("test error");
assert_eq!(err.exit_code(), 1);
}
#[test]
fn test_system_error_exit_code() {
let err = CliError::system("test error");
assert_eq!(err.exit_code(), 101);
}
#[test]
fn test_string_into_response() {
let response = "hello".to_string().into_response();
assert_eq!(response.exit_code, 0);
assert!(matches!(response.output, Output::Text(_)));
}
#[test]
fn test_unit_into_response() {
let response = ().into_response();
assert_eq!(response.exit_code, 0);
assert!(matches!(response.output, Output::Silent));
}
#[test]
fn test_result_ok_into_response() {
let result: CliResult<String> = Ok("success".to_string());
let response = result.into_response();
assert_eq!(response.exit_code, 0);
}
#[test]
fn test_result_err_into_response() {
let result: CliResult<String> = Err(CliError::user("failure"));
let response = result.into_response();
assert_eq!(response.exit_code, 1);
}
#[tokio::test]
async fn test_router_basic() {
#[derive(Clone)]
struct AppState {
value: i32,
}
async fn get_value(state: State<AppState>) -> CliResult<String> {
let app = state.read().await;
Ok(format!("Value: {}", app.value))
}
let state = AppState { value: 42 };
let router = Router::new().route("status", get_value).with_state(state);
let response = router
.execute_with(&["test".to_string(), "status".to_string()])
.await;
assert_eq!(response.exit_code, 0);
assert!(matches!(response.output, Output::Text(_)));
}
#[tokio::test]
async fn test_router_unknown_command() {
let router: Router<()> = Router::new().with_state(());
let response = router
.execute_with(&["test".to_string(), "unknown".to_string()])
.await;
assert_eq!(response.exit_code, 1);
}
#[tokio::test]
async fn test_router_no_command() {
let router: Router<()> = Router::new().with_state(());
let response = router.execute_with(&["test".to_string()]).await;
assert_eq!(response.exit_code, 0);
}
#[tokio::test]
async fn test_router_multiple_routes() {
#[derive(Clone)]
struct AppState {
#[allow(dead_code)]
count: i32,
}
async fn status(_state: State<AppState>) -> CliResult<String> {
Ok("OK".to_string())
}
async fn version(_state: State<AppState>) -> CliResult<String> {
Ok("v1.0.0".to_string())
}
let state = AppState { count: 0 };
let router = Router::new()
.route("status", status)
.route("version", version)
.with_state(state);
let response1 = router
.execute_with(&["test".to_string(), "status".to_string()])
.await;
assert_eq!(response1.exit_code, 0);
let response2 = router
.execute_with(&["test".to_string(), "version".to_string()])
.await;
assert_eq!(response2.exit_code, 0);
}
#[tokio::test]
async fn test_router_with_args() {
#[derive(Clone)]
struct AppState {
#[allow(dead_code)]
base_cmd: String,
}
#[derive(Debug)]
struct BuildArgs {
release: bool,
}
impl FromArgs for BuildArgs {
fn from_args(args: &[String]) -> Result<Self, CliError> {
Ok(BuildArgs {
release: args.first().map(|s| s == "--release").unwrap_or(false),
})
}
}
async fn build(_state: State<AppState>, Args(args): Args<BuildArgs>) -> CliResult<String> {
if args.release {
Ok("release".to_string())
} else {
Ok("debug".to_string())
}
}
let state = AppState {
base_cmd: "cargo build".to_string(),
};
let router = Router::new().route("build", build).with_state(state);
let response1 = router
.execute_with(&[
"test".to_string(),
"build".to_string(),
"--release".to_string(),
])
.await;
assert_eq!(response1.exit_code, 0);
if let Output::Text(output) = response1.output {
assert_eq!(output, "release");
}
let response2 = router
.execute_with(&["test".to_string(), "build".to_string()])
.await;
assert_eq!(response2.exit_code, 0);
if let Output::Text(output) = response2.output {
assert_eq!(output, "debug");
}
}
#[tokio::test]
async fn test_router_args_no_state() {
#[derive(Debug)]
struct EchoArgs {
message: String,
}
impl FromArgs for EchoArgs {
fn from_args(args: &[String]) -> Result<Self, CliError> {
let message = args.first().cloned().unwrap_or_else(|| "".to_string());
Ok(EchoArgs { message })
}
}
async fn echo(Args(args): Args<EchoArgs>) -> CliResult<String> {
Ok(args.message)
}
let router = Router::new().route("echo", echo).with_state(());
let response = router
.execute_with(&["test".to_string(), "echo".to_string(), "Hello!".to_string()])
.await;
assert_eq!(response.exit_code, 0);
if let Output::Text(output) = response.output {
assert_eq!(output, "Hello!");
}
}
#[tokio::test]
async fn test_router_args_parse_error() {
#[derive(Debug)]
struct StrictArgs;
impl FromArgs for StrictArgs {
fn from_args(args: &[String]) -> Result<Self, CliError> {
if args.is_empty() {
Err(CliError::user("Arguments required"))
} else {
Ok(StrictArgs)
}
}
}
async fn strict(Args(_args): Args<StrictArgs>) -> CliResult<String> {
Ok("success".to_string())
}
let router = Router::new().route("strict", strict).with_state(());
let response = router
.execute_with(&["test".to_string(), "strict".to_string()])
.await;
assert_eq!(response.exit_code, 1);
}
#[tokio::test]
async fn test_router_nest_basic() {
#[derive(Clone)]
struct AppState;
async fn db_create(_state: State<AppState>) -> CliResult<String> {
Ok("DB created".to_string())
}
async fn db_list(_state: State<AppState>) -> CliResult<String> {
Ok("DB list".to_string())
}
let db_router = Router::new()
.route("create", db_create)
.route("list", db_list);
let router = Router::new().nest("db", db_router).with_state(AppState);
let response = router
.execute_with(&["test".to_string(), "db".to_string(), "create".to_string()])
.await;
assert_eq!(response.exit_code, 0);
if let Output::Text(output) = response.output {
assert_eq!(output, "DB created");
}
let response = router
.execute_with(&["test".to_string(), "db".to_string(), "list".to_string()])
.await;
assert_eq!(response.exit_code, 0);
if let Output::Text(output) = response.output {
assert_eq!(output, "DB list");
}
}
#[tokio::test]
async fn test_router_nest_with_args() {
#[derive(Clone)]
struct AppState;
#[derive(Debug)]
struct CreateArgs {
name: String,
}
impl FromArgs for CreateArgs {
fn from_args(args: &[String]) -> Result<Self, CliError> {
let name = args
.first()
.cloned()
.unwrap_or_else(|| "default".to_string());
Ok(CreateArgs { name })
}
}
async fn db_create(
_state: State<AppState>,
Args(args): Args<CreateArgs>,
) -> CliResult<String> {
Ok(format!("Created DB: {}", args.name))
}
let db_router = Router::new().route("create", db_create);
let router = Router::new().nest("db", db_router).with_state(AppState);
let response = router
.execute_with(&[
"test".to_string(),
"db".to_string(),
"create".to_string(),
"mydb".to_string(),
])
.await;
assert_eq!(response.exit_code, 0);
if let Output::Text(output) = response.output {
assert_eq!(output, "Created DB: mydb");
}
}
#[tokio::test]
async fn test_router_nest_multiple_levels() {
#[derive(Clone)]
struct AppState;
async fn status(_state: State<AppState>) -> CliResult<String> {
Ok("OK".to_string())
}
async fn db_create(_state: State<AppState>) -> CliResult<String> {
Ok("DB created".to_string())
}
async fn server_start(_state: State<AppState>) -> CliResult<String> {
Ok("Server started".to_string())
}
let db_router = Router::new().route("create", db_create);
let server_router = Router::new().route("start", server_start);
let router = Router::new()
.route("status", status) .nest("db", db_router) .nest("server", server_router)
.with_state(AppState);
let response = router
.execute_with(&["test".to_string(), "status".to_string()])
.await;
assert_eq!(response.exit_code, 0);
let response = router
.execute_with(&["test".to_string(), "db".to_string(), "create".to_string()])
.await;
assert_eq!(response.exit_code, 0);
let response = router
.execute_with(&[
"test".to_string(),
"server".to_string(),
"start".to_string(),
])
.await;
assert_eq!(response.exit_code, 0);
}
#[tokio::test]
async fn test_router_nest_unknown_subcommand() {
#[derive(Clone)]
struct AppState;
async fn db_create(_state: State<AppState>) -> CliResult<String> {
Ok("DB created".to_string())
}
let db_router = Router::new().route("create", db_create);
let router = Router::new().nest("db", db_router).with_state(AppState);
let response = router
.execute_with(&["test".to_string(), "db".to_string(), "delete".to_string()])
.await;
assert_eq!(response.exit_code, 1);
}
#[cfg(feature = "unstable-clap-tests")]
#[tokio::test]
async fn test_clap_integration_basic() {
mod test_scope {
use super::*;
use clap::Parser;
#[derive(Clone)]
pub struct AppState;
#[derive(Parser, Debug)]
pub struct BuildArgs {
pub name: String,
#[arg(long)]
pub release: bool,
}
pub async fn build(
_state: State<AppState>,
Args(args): Args<BuildArgs>,
) -> CliResult<String> {
if args.release {
Ok(format!("Release build: {}", args.name))
} else {
Ok(format!("Debug build: {}", args.name))
}
}
}
let router = Router::new()
.route("build", test_scope::build)
.with_state(test_scope::AppState);
let response = router
.execute(&[
"build".to_string(),
"myapp".to_string(),
"--release".to_string(),
])
.await;
assert_eq!(response.exit_code, 0);
if let Output::Text(output) = response.output {
assert_eq!(output, "Release build: myapp");
}
let response = router
.execute(&["build".to_string(), "myapp".to_string()])
.await;
assert_eq!(response.exit_code, 0);
if let Output::Text(output) = response.output {
assert_eq!(output, "Debug build: myapp");
}
}
#[cfg(feature = "unstable-clap-tests")]
#[tokio::test]
async fn test_clap_integration_with_env() {
mod test_scope {
use super::*;
use clap::Parser;
#[derive(Clone)]
pub struct AppState;
#[derive(Parser, Debug)]
pub struct DeployArgs {
pub app: String,
#[arg(long, env = "DEPLOY_ENV", default_value = "production")]
pub env: String,
}
pub async fn deploy(
_state: State<AppState>,
Args(args): Args<DeployArgs>,
) -> CliResult<String> {
Ok(format!("Deploying {} to {}", args.app, args.env))
}
}
let router = Router::new()
.route("deploy", test_scope::deploy)
.with_state(test_scope::AppState);
let response = router
.execute(&[
"deploy".to_string(),
"myapp".to_string(),
"--env".to_string(),
"staging".to_string(),
])
.await;
assert_eq!(response.exit_code, 0);
if let Output::Text(output) = response.output {
assert_eq!(output, "Deploying myapp to staging");
}
let response = router
.execute(&["deploy".to_string(), "myapp".to_string()])
.await;
assert_eq!(response.exit_code, 0);
if let Output::Text(output) = response.output {
assert_eq!(output, "Deploying myapp to production");
}
}
#[cfg(feature = "unstable-clap-tests")]
#[tokio::test]
async fn test_clap_integration_validation() {
mod test_scope {
use super::*;
use clap::Parser;
#[derive(Clone)]
pub struct AppState;
#[derive(Parser, Debug)]
pub struct CreateArgs {
pub name: String,
#[arg(long, value_parser = clap::value_parser!(u16).range(1..=65535))]
pub port: Option<u16>,
}
pub async fn create(
_state: State<AppState>,
Args(args): Args<CreateArgs>,
) -> CliResult<String> {
Ok(format!("Created {} on port {:?}", args.name, args.port))
}
}
let router = Router::new()
.route("create", test_scope::create)
.with_state(test_scope::AppState);
let response = router
.execute(&[
"create".to_string(),
"mydb".to_string(),
"--port".to_string(),
"3000".to_string(),
])
.await;
assert_eq!(response.exit_code, 0);
let response = router
.execute(&[
"create".to_string(),
"mydb".to_string(),
"--port".to_string(),
"99999".to_string(),
])
.await;
assert_eq!(response.exit_code, 1);
}
#[tokio::test]
async fn test_router_with_agent_mode_enabled() {
async fn status(_state: State<()>) -> CliResult<String> {
Ok("Status: OK".to_string())
}
let router = Router::new()
.route("status", status)
.with_agent_mode()
.with_state(());
let response = router
.execute_with(&["test".to_string(), "status".to_string()])
.await;
assert_eq!(response.exit_code, 0);
assert!(!response.agent_mode);
let response = router
.execute_with(&[
"test".to_string(),
"--agent-mode".to_string(),
"status".to_string(),
])
.await;
assert_eq!(response.exit_code, 0);
assert!(response.agent_mode);
}
#[tokio::test]
async fn test_router_without_agent_mode_enabled() {
async fn status(_state: State<()>) -> CliResult<String> {
Ok("Status: OK".to_string())
}
let router = Router::new().route("status", status).with_state(());
let response = router
.execute_with(&[
"test".to_string(),
"--agent-mode".to_string(),
"status".to_string(),
])
.await;
assert_eq!(response.exit_code, 1); assert!(!response.agent_mode);
let response = router
.execute_with(&["test".to_string(), "status".to_string()])
.await;
assert_eq!(response.exit_code, 0);
assert!(!response.agent_mode);
}
#[tokio::test]
async fn test_agent_mode_flag_stripped_from_args() {
#[derive(Debug)]
struct TestArgs {
message: String,
}
impl FromArgs for TestArgs {
fn from_args(args: &[String]) -> Result<Self, CliError> {
for arg in args {
if arg == "--agent-mode" {
return Err(CliError::user(
"Unexpected --agent-mode flag in handler args",
));
}
}
let message = args.first().cloned().unwrap_or_else(|| "empty".to_string());
Ok(TestArgs { message })
}
}
async fn echo(Args(args): Args<TestArgs>) -> CliResult<String> {
Ok(args.message)
}
let router = Router::new()
.route("echo", echo)
.with_agent_mode()
.with_state(());
let response = router
.execute_with(&[
"test".to_string(),
"--agent-mode".to_string(),
"echo".to_string(),
"hello".to_string(),
])
.await;
assert_eq!(response.exit_code, 0);
assert!(response.agent_mode);
if let Output::Text(output) = response.output {
assert_eq!(output, "hello");
} else {
panic!("Expected text output");
}
}
}