use anyhow::{Context, Result};
use bmux_cli_schema::{Cli, LogLevel};
use bmux_config::{BmuxConfig, ConfigLoadOverrides, ConfigPaths};
use bmux_plugin::PluginRegistry;
use bmux_plugin_sdk::perf_telemetry::{PhaseChannel, PhasePayload, emit as emit_phase_timing};
use clap::{CommandFactory, FromArgMatches};
use std::sync::Arc;
use std::time::Instant;
use tracing::Level;
use super::plugin_runtime::{RuntimeCommandState, build_runtime_command_state};
use super::{
effective_enabled_plugins, install_enabled_client_adapters, plugin_commands,
plugin_commands::PluginCommandRegistry, scan_available_plugins,
};
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub(super) enum ParsedRuntimeCli {
BuiltIn {
cli: Cli,
log_level: LogLevel,
verbose: bool,
config_overrides: ConfigLoadOverrides,
},
ImmediateExit {
code: u8,
output: String,
stderr: bool,
},
Plugin {
log_level: LogLevel,
plugin_id: String,
command_name: String,
arguments: Vec<String>,
command_state: Option<RuntimeCommandState>,
config_overrides: ConfigLoadOverrides,
},
}
#[derive(Debug, Clone, Default)]
struct RawRuntimeOverrides {
config_path: Option<std::path::PathBuf>,
}
pub(super) fn parse_runtime_cli() -> Result<ParsedRuntimeCli> {
let total_started = Instant::now();
let argv_started = Instant::now();
let argv = std::env::args_os().collect::<Vec<_>>();
let raw_overrides = apply_runtime_override_from_raw_args(&argv)?;
let argv_us = argv_started.elapsed().as_micros();
let slot_started = Instant::now();
let slot_state = super::slot::active_slot().clone();
if let super::slot::ActiveSlotState::Resolved { slot, .. } = &slot_state {
unsafe { std::env::set_var(bmux_slots::SLOT_NAME_ENV, &slot.name) };
}
if let super::slot::ActiveSlotState::Unknown { name, known, .. } = &slot_state {
anyhow::bail!(
"active slot {name:?} is not declared in the slot manifest (known: {known:?}). \
Add it to slots.toml or unset BMUX_SLOT_NAME."
);
}
let slot_us = slot_started.elapsed().as_micros();
let config_started = Instant::now();
let mut config_overrides = ConfigLoadOverrides::from_env_with_cli(raw_overrides.config_path);
if let super::slot::ActiveSlotState::Resolved { slot, .. } = &slot_state
&& slot.inherit_base
{
config_overrides.base_config_path = Some(bmux_slots::default_base_config_path());
}
let (config, paths) = if let super::slot::ActiveSlotState::Resolved { slot, .. } = &slot_state {
let paths = ConfigPaths::for_slot(slot);
let cfg = BmuxConfig::load_with_paths_and_overrides(&paths, &config_overrides)?;
(cfg, paths)
} else {
let cfg = BmuxConfig::load_with_overrides(&config_overrides)?;
(cfg, ConfigPaths::default())
};
let config_us = config_started.elapsed().as_micros();
let scan_started = Instant::now();
let registry = Arc::new(scan_available_plugins(&config, &paths)?);
let scan_us = scan_started.elapsed().as_micros();
let state_started = Instant::now();
let command_state = build_runtime_command_state(config.clone(), paths, Arc::clone(®istry))?;
install_enabled_client_adapters(&config, ®istry, &command_state.paths);
let state_us = state_started.elapsed().as_micros();
let parse_started = Instant::now();
let parsed = parse_runtime_cli_with_registry(
&argv,
&config,
®istry,
Some(command_state),
config_overrides,
)?;
emit_parse_phase_timing(
argv_us,
slot_us,
config_us,
scan_us,
state_us,
parse_started.elapsed().as_micros(),
total_started.elapsed().as_micros(),
);
Ok(parsed)
}
#[allow(clippy::too_many_arguments)]
fn emit_parse_phase_timing(
argv_us: u128,
slot_us: u128,
config_us: u128,
scan_us: u128,
state_us: u128,
parse_us: u128,
total_us: u128,
) {
let payload = PhasePayload::new("parse_runtime_cli")
.field("argv_us", argv_us)
.field("slot_us", slot_us)
.field("config_us", config_us)
.field("scan_us", scan_us)
.field("state_us", state_us)
.field("parse_us", parse_us)
.field("total_us", total_us)
.finish();
emit_phase_timing(PhaseChannel::Plugin, &payload);
}
fn apply_runtime_override_from_raw_args(
argv: &[std::ffi::OsString],
) -> Result<RawRuntimeOverrides> {
let mut overrides = RawRuntimeOverrides::default();
let mut index = 1usize;
while index < argv.len() {
let arg = argv[index].to_string_lossy();
if arg == "--" {
break;
}
if let Some(value) = arg.strip_prefix("--runtime=") {
let runtime = validate_runtime_name(value)?;
unsafe { std::env::set_var("BMUX_RUNTIME_NAME", runtime) };
index += 1;
continue;
}
if arg == "--runtime" {
let Some(value) = argv.get(index + 1) else {
anyhow::bail!("--runtime requires a value")
};
let runtime = validate_runtime_name(&value.to_string_lossy())?;
unsafe { std::env::set_var("BMUX_RUNTIME_NAME", runtime) };
index += 2;
continue;
}
if let Some(value) = arg.strip_prefix("--config=") {
let path = resolve_cli_path_override(value, "--config")?;
overrides.config_path = Some(std::path::PathBuf::from(path));
index += 1;
continue;
}
if arg == "--config" {
let Some(value) = argv.get(index + 1) else {
anyhow::bail!("--config requires a value")
};
let path = resolve_cli_path_override(&value.to_string_lossy(), "--config")?;
overrides.config_path = Some(std::path::PathBuf::from(path));
index += 2;
continue;
}
index += 1;
}
Ok(overrides)
}
fn resolve_cli_path_override(value: &str, flag: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
anyhow::bail!("{flag} requires a non-empty path")
}
let path = std::path::PathBuf::from(trimmed);
let resolved = if path.is_absolute() {
path
} else {
std::env::current_dir()
.with_context(|| format!("failed resolving relative path for {flag}"))?
.join(path)
};
Ok(resolved.to_string_lossy().into_owned())
}
fn validate_runtime_name(value: &str) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
anyhow::bail!("runtime name cannot be empty")
}
if trimmed == "default" {
return Ok(trimmed.to_string());
}
if trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
{
return Ok(trimmed.to_string());
}
anyhow::bail!("runtime name can only include letters, numbers, '-', '_' or '.'")
}
#[allow(clippy::too_many_lines)] pub(super) fn parse_runtime_cli_with_registry(
argv: &[std::ffi::OsString],
config: &BmuxConfig,
registry: &PluginRegistry,
command_state: Option<RuntimeCommandState>,
config_overrides: ConfigLoadOverrides,
) -> Result<ParsedRuntimeCli> {
let total_started = Instant::now();
let mut command_config = config.clone();
command_config.plugins.enabled = command_state.as_ref().map_or_else(
|| effective_enabled_plugins(config, registry),
|state| state.enabled_plugins.clone(),
);
let registry_started = Instant::now();
let command_registry = PluginCommandRegistry::build(&command_config, registry)
.context("failed building plugin CLI command registry")?;
let registry_us = registry_started.elapsed().as_micros();
let resolve_started = Instant::now();
if let Some(raw_args) = argv
.iter()
.skip(1)
.map(|arg| arg.to_str().map(ToString::to_string))
.collect::<Option<Vec<_>>>()
&& let Some(resolved) = command_registry.resolve(&raw_args)
{
let normalized = match PluginCommandRegistry::validate_arguments(
&resolved.schema,
&resolved.arguments,
) {
Ok(arguments) => arguments,
Err(error) => {
if let Some(error) = error.downcast_ref::<clap::Error>() {
return Ok(ParsedRuntimeCli::ImmediateExit {
code: if error.use_stderr() { 2 } else { 0 },
output: error.to_string(),
stderr: error.use_stderr(),
});
}
return Err(error);
}
};
let verbose = raw_args.iter().any(|arg| arg == "--verbose" || arg == "-v");
let log_level = resolve_log_level(
verbose,
None,
std::env::var("BMUX_LOG_LEVEL").ok().as_deref(),
);
if !raw_args.iter().any(|arg| arg == "--core-builtins-only") {
emit_parse_with_registry_phase_timing(
registry_us,
resolve_started.elapsed().as_micros(),
0,
0,
total_started.elapsed().as_micros(),
);
return Ok(ParsedRuntimeCli::Plugin {
log_level,
plugin_id: resolved.plugin_id,
command_name: resolved.command_name,
arguments: normalized,
command_state,
config_overrides,
});
}
}
let resolve_us = resolve_started.elapsed().as_micros();
let clap_augment_started = Instant::now();
let clap_command = command_registry
.augment_clap_command(Cli::command())
.context("failed augmenting CLI with plugin commands")?;
let clap_augment_us = clap_augment_started.elapsed().as_micros();
let clap_parse_started = Instant::now();
let matches = match clap_command.try_get_matches_from(argv.iter().cloned()) {
Ok(matches) => matches,
Err(error) => {
return Ok(match error.kind() {
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
ParsedRuntimeCli::ImmediateExit {
code: 0,
output: error.to_string(),
stderr: false,
}
}
_ => ParsedRuntimeCli::ImmediateExit {
code: 2,
output: error.to_string(),
stderr: true,
},
});
}
};
let clap_parse_us = clap_parse_started.elapsed().as_micros();
emit_parse_with_registry_phase_timing(
registry_us,
resolve_us,
clap_augment_us,
clap_parse_us,
total_started.elapsed().as_micros(),
);
let verbose = matches.get_flag("verbose");
let log_level = resolve_log_level(
verbose,
matches.get_one::<LogLevel>("log_level").copied(),
std::env::var("BMUX_LOG_LEVEL").ok().as_deref(),
);
let core_builtins_only = matches.get_flag("core_builtins_only");
if !core_builtins_only {
let (path, leaf_matches) = plugin_commands::selected_subcommand_path(&matches);
if let Some(resolved) = command_registry.resolve_exact_path(&path) {
let arguments = leading_global_prefix_len(argv)
.and_then(|start| {
argv[start..]
.iter()
.map(|arg| arg.to_str().map(ToString::to_string))
.collect::<Option<Vec<_>>>()
})
.and_then(|raw| command_registry.resolve(&raw))
.filter(|raw| {
raw.plugin_id == resolved.plugin_id && raw.command_name == resolved.command_name
})
.map_or_else(
|| {
PluginCommandRegistry::normalize_arguments_from_matches(
&resolved.schema,
leaf_matches,
)
},
|raw| raw.arguments,
);
return Ok(ParsedRuntimeCli::Plugin {
log_level,
plugin_id: resolved.plugin_id,
command_name: resolved.command_name,
arguments,
command_state,
config_overrides,
});
}
}
let cli =
Cli::from_arg_matches(&matches).map_err(|error| anyhow::anyhow!(error.to_string()))?;
Ok(ParsedRuntimeCli::BuiltIn {
cli,
log_level,
verbose,
config_overrides,
})
}
fn leading_global_prefix_len(argv: &[std::ffi::OsString]) -> Option<usize> {
let root = Cli::command();
let mut index = 1;
while let Some(value) = argv.get(index) {
let value = value.to_str()?;
if !value.starts_with('-') {
return Some(index);
}
let (flag, inline) = value
.split_once('=')
.map_or((value, false), |(flag, _)| (flag, true));
let argument = root.get_arguments().find(|argument| {
argument
.get_long()
.is_some_and(|long| flag.strip_prefix("--") == Some(long))
|| argument
.get_short()
.is_some_and(|short| flag == format!("-{short}"))
})?;
let arity = argument.get_num_args().unwrap_or_else(|| {
if argument.get_action().takes_values() {
clap::builder::ValueRange::new(1)
} else {
clap::builder::ValueRange::new(0)
}
});
if arity.min_values() != arity.max_values() || arity.max_values() > 1 {
return None;
}
index += 1 + usize::from(!inline && arity.max_values() == 1);
}
None
}
#[cfg(test)]
mod proxy_argument_tests {
use super::*;
#[test]
fn leading_globals_preserve_host_leaf_proxy_flags() {
let mut registry = PluginRegistry::new();
registry.register_bundled_manifest(
"id='fixture.proxy'\nname='Proxy'\nversion='1.0.0'\nowns_namespaces=['terminal']\n[[commands]]\nname='terminal-doctor'\nsummary='Proxy diagnostic'\npath=['terminal','doctor']\nexpose_in_cli=true\n[[commands.arguments]]\nname='args'\nkind='string'\nposition=0\nmultiple=true\ntrailing_var_arg=true\nallow_hyphen_values=true\n",
).unwrap();
let mut config = BmuxConfig::default();
config.plugins.enabled.push("fixture.proxy".into());
let argv = [
"bmux",
"--runtime",
"default",
"--target",
"local",
"--config",
"/tmp/owned.toml",
"terminal",
"doctor",
"--json",
"--trace",
]
.map(std::ffi::OsString::from);
let parsed = parse_runtime_cli_with_registry(
&argv,
&config,
®istry,
None,
ConfigLoadOverrides::default(),
)
.unwrap();
match parsed {
ParsedRuntimeCli::Plugin { arguments, .. } => {
assert_eq!(arguments, ["--json", "--trace"]);
}
other => panic!("expected proxy, got {other:?}"),
}
}
}
fn emit_parse_with_registry_phase_timing(
registry_us: u128,
resolve_us: u128,
clap_augment_us: u128,
clap_parse_us: u128,
total_us: u128,
) {
let payload = PhasePayload::new("parse_runtime_cli_with_registry")
.field("command_registry_us", registry_us)
.field("resolve_us", resolve_us)
.field("clap_augment_us", clap_augment_us)
.field("clap_parse_us", clap_parse_us)
.field("total_us", total_us)
.finish();
emit_phase_timing(PhaseChannel::Plugin, &payload);
}
pub(super) fn resolve_log_level(
verbose: bool,
cli_level: Option<LogLevel>,
env_level: Option<&str>,
) -> LogLevel {
if let Some(level) = cli_level {
return level;
}
if verbose {
return LogLevel::Debug;
}
if let Some(raw) = env_level
&& let Some(level) = parse_log_level(raw)
{
return level;
}
LogLevel::Info
}
pub(super) fn parse_log_level(raw: &str) -> Option<LogLevel> {
match raw.trim().to_ascii_lowercase().as_str() {
"error" => Some(LogLevel::Error),
"warn" | "warning" => Some(LogLevel::Warn),
"info" => Some(LogLevel::Info),
"debug" => Some(LogLevel::Debug),
"trace" => Some(LogLevel::Trace),
_ => None,
}
}
pub(super) const fn tracing_level(level: LogLevel) -> Level {
match level {
LogLevel::Error => Level::ERROR,
LogLevel::Warn => Level::WARN,
LogLevel::Info => Level::INFO,
LogLevel::Debug => Level::DEBUG,
LogLevel::Trace => Level::TRACE,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_arg_config_override_returns_resolved_path() {
let args = vec![
std::ffi::OsString::from("bmux"),
std::ffi::OsString::from("--config"),
std::ffi::OsString::from("./test.toml"),
];
let overrides = apply_runtime_override_from_raw_args(&args).expect("apply overrides");
let path = overrides.config_path.expect("config path override set");
assert!(path.ends_with("test.toml"));
}
}