use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use alog::{MessageLevel, alog_channel, use_channel};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::capabilities::{BindingType, ToolName};
use crate::define_factory;
use crate::registry::ConfigConstructable;
use crate::utils::ui::Ui;
use_channel!("LNCHR");
#[async_trait]
pub trait Launcher: crate::registry::Named + Send + Sync {
fn name(&self) -> &str;
fn command(&self) -> &str;
async fn bind_capability(
&mut self,
capability: &dyn crate::capabilities::Capability,
) -> anyhow::Result<()>;
fn validate_command(&self) -> anyhow::Result<PathBuf>;
async fn env_overlay(&self, _ctx: &LaunchContext) -> anyhow::Result<Vec<EnvBinding>> {
Ok(vec![])
}
fn map_tool_name(&self, tool: &ToolName) -> Option<String> {
match tool {
ToolName::Other(raw) => Some(raw.clone()),
_ => None,
}
}
async fn launch(
&self,
args: &[String],
ctx: &LaunchContext,
ui: &dyn Ui,
) -> anyhow::Result<std::process::ExitStatus> {
let binary = self.validate_command()?;
let overlay = self.env_overlay(ctx).await?;
alog_channel!(MessageLevel::Debug2, "Env Overlay: {:#?}", overlay);
run_command(binary, &overlay, args, ctx, ui).await
}
}
fn translate_env_for_windows(binary: &PathBuf, env: &[EnvBinding]) -> Vec<EnvBinding> {
alog_channel!(MessageLevel::Debug2, "Translating for binary {:#?}", binary);
if !binary.to_string_lossy().to_lowercase().ends_with(".exe") {
return env.to_vec();
}
let mut out: Vec<_> = env
.iter()
.map(|b| {
let value = crate::config::translate_wsl_to_windows(&b.value)
.unwrap_or_else(|| b.value.clone());
EnvBinding {
key: b.key.clone(),
value,
}
})
.collect();
let wslenv = out
.iter()
.map(|b| b.key.clone())
.collect::<Vec<_>>()
.join(":");
out.push(EnvBinding {
key: "WSLENV".to_string(),
value: wslenv,
});
alog_channel!(MessageLevel::Debug3, "Translated env vars: {:#?}", out);
out
}
pub(crate) async fn run_command(
binary: PathBuf,
overlay: &[EnvBinding],
args: &[String],
ctx: &LaunchContext,
ui: &dyn Ui,
) -> anyhow::Result<std::process::ExitStatus> {
if ctx.dry_run {
ui.info(&format!("Would exec: {}", binary.display()));
ui.info(&format!(
" args: {}",
if args.is_empty() {
"(none)".to_string()
} else {
args.join(" ")
}
));
if overlay.is_empty() {
ui.info(" env overlay: (none)");
} else {
for binding in overlay {
ui.info(&format!(" env: {}={}", binding.key, binding.value));
}
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
return Ok(std::process::ExitStatus::from_raw(0));
}
#[cfg(windows)]
{
use std::os::windows::process::ExitStatusExt;
return Ok(std::process::ExitStatus::from_raw(0));
}
}
let translated_overlay = translate_env_for_windows(&binary, overlay);
let mut cmd = std::process::Command::new(&binary);
cmd.args(args);
for binding in &translated_overlay {
cmd.env(&binding.key, &binding.value);
}
#[cfg(windows)]
let (binary_fallback, args_fallback, overlay_fallback) =
(binary.clone(), args.to_vec(), translated_overlay);
tokio::task::spawn_blocking(move || -> anyhow::Result<std::process::ExitStatus> {
let mut spawn_cmd = cmd;
#[cfg(windows)]
if let Some(drive) = binary_fallback.parent().and_then(|p| {
p.to_str().and_then(|s| {
let chars: Vec<char> = s.chars().collect();
if chars.len() >= 2 && chars[1] == ':' {
Some(format!("{}\\", &s[..2]))
} else {
None
}
})
}) {
spawn_cmd.current_dir(drive);
}
let mut child = match spawn_cmd.spawn() {
Ok(child) => child,
#[allow(unreachable_code)]
Err(e) => {
#[cfg(windows)]
if let Some(193) = e.raw_os_error() {
let mut shell = std::process::Command::new("cmd.exe");
shell.arg("/C");
let mut cmd_str = String::new();
cmd_str.push_str(&binary_fallback.to_string_lossy());
for arg in &args_fallback {
cmd_str.push(' ');
cmd_str.push_str(arg);
}
shell.arg(&cmd_str);
for binding in &overlay_fallback {
shell.env(&binding.key, &binding.value);
}
return shell.spawn()?.wait().map_err(anyhow::Error::from);
}
return Err(e.into());
}
};
Ok(child.wait()?)
})
.await?
}
pub(crate) async fn run_command_captured(
binary: PathBuf,
overlay: &[EnvBinding],
args: &[String],
ctx: &LaunchContext,
) -> anyhow::Result<(std::process::ExitStatus, String, String)> {
if ctx.dry_run {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
return Ok((
std::process::ExitStatus::from_raw(0),
String::new(),
String::new(),
));
}
#[cfg(windows)]
{
use std::os::windows::process::ExitStatusExt;
return Ok((
std::process::ExitStatus::from_raw(0),
String::new(),
String::new(),
));
}
}
let translated_overlay = translate_env_for_windows(&binary, overlay);
let mut cmd = std::process::Command::new(&binary);
cmd.args(args);
for binding in &translated_overlay {
cmd.env(&binding.key, &binding.value);
}
#[cfg(windows)]
let (binary_fallback, args_fallback, overlay_fallback) =
(binary.clone(), args.to_vec(), translated_overlay);
tokio::task::spawn_blocking(
move || -> anyhow::Result<(std::process::ExitStatus, String, String)> {
let mut spawn_cmd = cmd;
#[cfg(windows)]
if let Some(drive) = binary_fallback.parent().and_then(|p| {
p.to_str().and_then(|s| {
let chars: Vec<char> = s.chars().collect();
if chars.len() >= 2 && chars[1] == ':' {
Some(format!("{}\\", &s[..2]))
} else {
None
}
})
}) {
spawn_cmd.current_dir(drive);
}
let output = match spawn_cmd.output() {
Ok(output) => output,
#[allow(unreachable_code)]
Err(e) => {
#[cfg(windows)]
if let Some(193) = e.raw_os_error() {
let mut shell = std::process::Command::new("cmd.exe");
shell.arg("/C");
let mut cmd_str = String::new();
cmd_str.push_str(&binary_fallback.to_string_lossy());
for arg in &args_fallback {
cmd_str.push(' ');
cmd_str.push_str(arg);
}
shell.arg(&cmd_str);
for binding in &overlay_fallback {
shell.env(&binding.key, &binding.value);
}
return shell
.output()
.map(|o| {
(
o.status,
String::from_utf8_lossy(&o.stdout).into_owned(),
String::from_utf8_lossy(&o.stderr).into_owned(),
)
})
.map_err(anyhow::Error::from);
}
return Err(e.into());
}
};
Ok((
output.status,
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
))
},
)
.await?
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LauncherMetadata {
pub name: String,
pub description: String,
pub default_command: String,
pub supported_capabilities: HashSet<BindingType>,
pub tags: Vec<String>,
}
impl std::fmt::Display for LauncherMetadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)
}
}
pub struct LaunchContext {
pub launcher_id: String,
pub working_dir: PathBuf,
pub base_env: HashMap<String, String>,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct EnvBinding {
pub key: String,
pub value: String,
}
define_factory!(Launcher, LauncherMetadata, LauncherFactory);
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::registry::ConfigConstructable;
pub(crate) struct FakeLauncher {
instance_id: String,
command_path: Option<PathBuf>,
command_name: String,
}
impl crate::registry::Named for FakeLauncher {
fn instance_id(&self) -> &str {
&self.instance_id
}
}
impl ConfigConstructable for FakeLauncher {
type Config = crate::registry::NoConfig;
fn new(
instance_id: &str,
cfg: &serde_json::Value,
_global_config: &crate::config::Config,
) -> Self {
let command_name = cfg
.get("command_name")
.and_then(|v| v.as_str())
.unwrap_or("fake-binary-that-does-not-exist")
.to_string();
let command_path = cfg
.get("command_path")
.and_then(|v| v.as_str())
.map(PathBuf::from);
Self {
instance_id: instance_id.to_string(),
command_name,
command_path,
}
}
}
#[async_trait]
impl Launcher for FakeLauncher {
fn name(&self) -> &str {
"Fake Launcher"
}
fn command(&self) -> &str {
&self.command_name
}
async fn bind_capability(
&mut self,
_capability: &dyn crate::capabilities::Capability,
) -> anyhow::Result<()> {
anyhow::bail!("Capability binding not supported");
}
fn validate_command(&self) -> anyhow::Result<PathBuf> {
crate::utils::resolve_shell_command(
&self
.command_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
&self.command_name,
)
}
}
impl HasLauncherMetadata for FakeLauncher {
fn metadata() -> LauncherMetadata {
LauncherMetadata {
name: "Fake Launcher".to_string(),
description: "Test double".to_string(),
default_command: "fake-binary-that-does-not-exist".to_string(),
supported_capabilities: HashSet::new(),
tags: vec![],
}
}
}
#[test]
fn validate_command_returns_err_for_unknown_binary() {
let launcher = FakeLauncher::new(
"my-fake",
&serde_json::json!({
"command_name": "this-binary-absolutely-does-not-exist-9x7z"
}),
&crate::config::Config::default(),
);
assert!(launcher.validate_command().is_err());
}
#[test]
fn validate_command_returns_err_for_nonexistent_explicit_path() {
let launcher = FakeLauncher::new(
"my-fake",
&serde_json::json!({
"command_name": "fake",
"command_path": "/this/path/does/not/exist/fake"
}),
&crate::config::Config::default(),
);
assert!(launcher.validate_command().is_err());
}
#[test]
fn validate_command_falls_back_to_path_for_bare_command_name() {
let launcher = FakeLauncher::new(
"my-fake",
&serde_json::json!({
"command_path": "ls"
}),
&crate::config::Config::default(),
);
assert!(launcher.validate_command().is_ok());
}
#[tokio::test]
async fn env_overlay_default_is_empty() {
let launcher = FakeLauncher::new(
"my-fake",
&serde_json::json!({}),
&crate::config::Config::default(),
);
let ctx = LaunchContext {
launcher_id: "test".to_string(),
working_dir: PathBuf::from("/tmp"),
base_env: HashMap::new(),
dry_run: false,
};
let overlay = launcher.env_overlay(&ctx).await.unwrap();
assert!(overlay.is_empty());
}
#[test]
fn map_tool_name_default_passes_through_other_and_returns_none_for_everything_else() {
let launcher = FakeLauncher::new(
"my-fake",
&serde_json::json!({}),
&crate::config::Config::default(),
);
assert_eq!(
launcher.map_tool_name(&ToolName::Other("SomeRawTool".to_string())),
Some("SomeRawTool".to_string())
);
assert_eq!(launcher.map_tool_name(&ToolName::FileRead), None);
assert_eq!(
launcher.map_tool_name(&ToolName::Mcp {
server: "vision".to_string(),
tool: None,
}),
None
);
}
#[test]
fn launcher_factory_register_and_get() {
let mut factory = LauncherFactory::new();
factory.register::<FakeLauncher>("fake");
assert!(factory.get("fake").is_some());
assert!(factory.get("nonexistent").is_none());
}
#[test]
fn launcher_factory_construct() {
let mut factory = LauncherFactory::new();
factory.register::<FakeLauncher>("fake");
let result = factory.construct(
"fake",
"my-fake",
&serde_json::json!({}),
&crate::config::Config::default(),
);
assert!(result.is_ok());
}
#[test]
fn launcher_metadata_display() {
let meta = LauncherMetadata {
name: "Test".to_string(),
description: "A test launcher".to_string(),
default_command: "test".to_string(),
supported_capabilities: HashSet::new(),
tags: vec![],
};
assert_eq!(meta.to_string(), "A test launcher");
}
#[tokio::test]
async fn run_command_dry_run_returns_success() {
use crate::utils::ui::backends::plain::PlainOutput;
let ui = PlainOutput;
let ctx = LaunchContext {
launcher_id: "test".to_string(),
working_dir: PathBuf::from("/tmp"),
base_env: HashMap::new(),
dry_run: true,
};
let status = run_command(
PathBuf::from("/usr/bin/echo"),
&[],
&["hello".to_string()],
&ctx,
&ui,
)
.await
.unwrap();
assert!(status.success());
}
#[tokio::test]
async fn run_command_non_dry_run_executes() {
use crate::utils::ui::backends::plain::PlainOutput;
let ui = PlainOutput;
let ctx = LaunchContext {
launcher_id: "test".to_string(),
working_dir: PathBuf::from("/tmp"),
base_env: HashMap::new(),
dry_run: false,
};
#[cfg(unix)]
let (binary, args) = (PathBuf::from("/bin/echo"), vec!["hello".to_string()]);
#[cfg(windows)]
let (binary, args) = (
PathBuf::from("cmd"),
vec!["/C".to_string(), "echo".to_string(), "hello".to_string()],
);
let status = run_command(binary, &[], &args, &ctx, &ui).await.unwrap();
assert!(status.success());
}
}