Skip to content
Merged
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
80 changes: 75 additions & 5 deletions src/cli/registry.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,94 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};

use super::Command;

/// Internal registry storage for command-to-command mappings.
#[derive(Default, Clone)]
pub(crate) struct CommandRegistry {
commands: BTreeMap<String, Command>,
aliases: BTreeMap<String, String>,
}

impl CommandRegistry {
pub(crate) fn get(&self, name: &str) -> Option<Command> {
self.commands.get(name).cloned()
if let Some(command) = self.commands.get(name) {
return Some(command.clone());
}

self.aliases
.get(name)
.and_then(|canonical| self.commands.get(canonical))
.cloned()
}

pub(crate) fn register(&mut self, command: Command) -> &mut CommandRegistry {
self.commands.insert(command.metadata.name.clone(), command);
self
pub(crate) fn register(&mut self, command: Command) -> Result<&mut CommandRegistry, String> {
self.validate_alias_collisions(&command)?;

let command_name = command.metadata.name.clone();

if let Some(previous) = self.commands.remove(&command_name) {
for alias in previous.metadata.aliases {
self.aliases.remove(&alias);
}
}

for alias in &command.metadata.aliases {
self.aliases.insert(alias.clone(), command_name.clone());
}

self.commands.insert(command_name, command);
Ok(self)
}

pub(crate) fn get_all(&self) -> Vec<Command> {
self.commands.values().cloned().collect()
}

fn validate_alias_collisions(&self, command: &Command) -> Result<(), String> {
if let Some(existing_owner) = self.aliases.get(&command.metadata.name)
&& existing_owner != &command.metadata.name
{
return Err(format!(
"command name '{}' conflicts with existing alias owned by '{}'",
command.metadata.name, existing_owner
));
}

let mut seen_aliases = BTreeSet::new();
for alias in &command.metadata.aliases {
if alias == &command.metadata.name {
return Err(format!(
"alias '{}' duplicates command name '{}'",
alias, command.metadata.name
));
}

if !seen_aliases.insert(alias.clone()) {
return Err(format!(
"alias '{}' is declared more than once for command '{}'",
alias, command.metadata.name
));
}

if let Some(existing_command) = self.commands.get(alias)
&& existing_command.metadata.name != command.metadata.name
{
return Err(format!(
"alias '{}' conflicts with existing command name '{}'",
alias, existing_command.metadata.name
));
}

if let Some(existing_owner) = self.aliases.get(alias)
&& existing_owner != &command.metadata.name
{
return Err(format!(
"alias '{}' conflicts with existing alias owned by '{}'",
alias, existing_owner
));
}
}

Ok(())
}
}
76 changes: 56 additions & 20 deletions src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,6 @@ use std::{error::Error, fmt, sync::Arc};

use crate::{Argument, Command, StrategyError, Switch, cli::CommandRegistry};

/// Controls how [`CliCore`] responds when the registry lock is poisoned.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum LockPoisonPolicy {
/// Panic immediately when a poisoned lock is encountered.
///
/// This is the default for CLI applications where lock poisoning indicates a
/// serious bug and silent recovery would hide inconsistent state.
FailFast = 0,
/// Recover by taking the poisoned inner value.
Recover = 1,
}

/// Renders user-facing help output from registered command metadata.
pub trait HelpRenderer: Send + Sync {
fn render(&self, caller: &str, registered_commands: &[Command]) -> String;
Expand Down Expand Up @@ -404,6 +391,8 @@ impl Default for CoreConfig {
/// Error returned by CMDkit during command routing and strategy execution.
#[derive(Debug)]
pub enum CMDKitError {
/// Command registration failed due to invalid or conflicting metadata.
Registration { message: String },
/// No command name was provided in argv.
MissingCommand { help: String },
/// The command name does not exist in the registry.
Expand All @@ -420,6 +409,9 @@ pub enum CMDKitError {
impl fmt::Display for CMDKitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Registration { message } => {
write!(f, "Command registration failed: {message}")
}
Self::MissingCommand { help } => {
write!(f, "No command provided.\n\n{help}")
}
Expand Down Expand Up @@ -468,16 +460,24 @@ impl CMDKit {
}

/// Runs the CLI with pre-built commands and prints user-facing errors.
///
/// This is a convenience wrapper that does not surface registration failures
/// to the caller. Prefer [`CMDKit::try_run_with_commands`] when callers need
/// to handle command-registration collisions programmatically.
pub fn run_with_commands(commands: &[Command]) {
if let Err(e) = Self::try_run_with_commands(commands) {
eprintln!("{e}");
}
}

/// Runs the CLI with pre-built commands and recoverable errors.
///
/// This is the preferred entrypoint for embedding and library use because it
/// returns structured registration errors (for example alias/name collisions)
/// instead of panicking.
pub fn try_run_with_commands(commands: &[Command]) -> Result<(), CMDKitError> {
Self::builder()
.with_commands(commands)
.try_with_commands(commands)?
.build()
.try_run_from_env()
}
Expand Down Expand Up @@ -568,9 +568,26 @@ impl CMDKitBuilder {
}

/// Registers a command into this runtime instance.
pub fn register(mut self, command: Command) -> Self {
self.registry.register(command);
self
///
/// Prefer [`CMDKitBuilder::try_register`] when command metadata can come from
/// external or dynamic sources.
///
/// # Panics
/// Panics when registration fails (for example, alias/name collisions).
pub fn register(self, command: Command) -> Self {
self.try_register(command)
.expect("command registration should succeed")
}

/// Registers a command and returns a structured error on failure.
///
/// This is the preferred registration API because command registration is
/// fallible: aliases and command names are validated for collisions.
pub fn try_register(mut self, command: Command) -> Result<Self, CMDKitError> {
self.registry
.register(command)
.map_err(|message| CMDKitError::Registration { message })?;
Ok(self)
}

fn new() -> CMDKitBuilder {
Expand All @@ -580,11 +597,30 @@ impl CMDKitBuilder {
}
}

pub fn with_commands(mut self, commands: &[Command]) -> Self {
/// Registers multiple commands into this runtime instance.
///
/// Prefer [`CMDKitBuilder::try_with_commands`] in library and embedding
/// scenarios so registration failures can be handled by the caller.
///
/// # Panics
/// Panics when any command registration fails (for example, alias/name
/// collisions).
pub fn with_commands(self, commands: &[Command]) -> Self {
self.try_with_commands(commands)
.expect("bulk command registration should succeed")
}

/// Registers multiple commands and returns a structured error on failure.
///
/// This is the preferred bulk-registration API because registration is
/// fallible and should generally be handled by the caller.
pub fn try_with_commands(mut self, commands: &[Command]) -> Result<Self, CMDKitError> {
for cmd in commands {
self.registry.register(cmd.clone());
self.registry
.register(cmd.clone())
.map_err(|message| CMDKitError::Registration { message })?;
}
self
Ok(self)
}

pub fn with_argument_interpreter<I>(mut self, interpreter: I) -> Self
Expand Down
11 changes: 1 addition & 10 deletions src/core/tests.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use std::sync::{Arc, Mutex};

use crate::{
CMDKit, CMDKitError, Command, CoreConfig, InvocationArgs, PlainTextHelpRenderer, StrategyError,
argument, command,
CMDKit, CMDKitError, Command, CoreConfig, InvocationArgs, StrategyError, argument, command,
};

struct MarkerHelpRenderer;
Expand Down Expand Up @@ -153,11 +152,3 @@ fn builder_with_config_uses_custom_renderer_for_missing_command_help() {
_ => panic!("expected missing command error"),
}
}

#[test]
fn lock_poison_policy_values_are_stable() {
assert_eq!(crate::core::LockPoisonPolicy::FailFast as u8, 0);
assert_eq!(crate::core::LockPoisonPolicy::Recover as u8, 1);

let _ = PlainTextHelpRenderer;
}
10 changes: 8 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,22 @@ pub use cli::{
};
pub use core::{
ArgumentInterpreter, CMDKit, CMDKitBuilder, CMDKitError, CoreConfig, HelpRenderer,
InvocationArgs, InvocationElement, LockPoisonPolicy, PlainTextArgumentInterpreter,
PlainTextHelpRenderer,
InvocationArgs, InvocationElement, PlainTextArgumentInterpreter, PlainTextHelpRenderer,
};

/// Runs a fresh default [`CMDKit`] instance with pre-built commands.
///
/// This is a convenience wrapper that prints errors. Prefer
/// [`try_run_with_commands`] when callers should handle registration failures
/// (such as alias/name collisions) programmatically.
pub fn run_with_commands(commands: &[Command]) {
core::CMDKit::run_with_commands(commands)
}

/// Runs a fresh default [`CMDKit`] instance with pre-built commands.
///
/// This is the preferred wrapper for library use because it returns
/// [`CMDKitError`] instead of hiding failure paths.
pub fn try_run_with_commands(commands: &[Command]) -> Result<(), CMDKitError> {
core::CMDKit::try_run_with_commands(commands)
}
Expand Down
Loading
Loading