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
}
}
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 mut cmd = std::process::Command::new(&binary);
cmd.args(args);
for binding in overlay {
cmd.env(&binding.key, &binding.value);
}
tokio::task::spawn_blocking(move || -> anyhow::Result<std::process::ExitStatus> {
Ok(cmd.spawn()?.wait()?)
})
.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)]
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,
};
let status = run_command(
PathBuf::from("/bin/echo"),
&[],
&["hello".to_string()],
&ctx,
&ui,
)
.await
.unwrap();
assert!(status.success());
}
}