#![cfg(unix)]
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::TempDir;
fn llman_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_llman"))
}
fn write_plugin(dir: &Path, name: &str, body: &str) {
let path = dir.join(name);
fs::write(&path, body).expect("write plugin script");
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod +x plugin");
}
fn echo_plugin_body() -> &'static str {
"#!/bin/sh\nprintf 'args=%s\\n' \"$*\"\nprintf 'config=%s\\n' \"$LLMAN_CONFIG_DIR\"\n"
}
fn run_llman_with_plugin_path(plugin_dir: &Path, args: &[&str]) -> Output {
run_llman_with_home(plugin_dir, None, args)
}
fn run_llman_with_home(plugin_dir: &Path, home: Option<&Path>, args: &[&str]) -> Output {
let isolated_home;
let home = match home {
Some(path) => path,
None => {
isolated_home = TempDir::new().expect("isolated home tempdir");
isolated_home.path()
}
};
let path_value = format!(
"{}:{}",
plugin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
run_llman_with_path_env(&path_value, home, args)
}
fn run_llman_with_path_env(path_value: &str, home: &Path, args: &[&str]) -> Output {
Command::new(llman_bin())
.args(args)
.env("PATH", path_value)
.env("HOME", home)
.env_remove("BUN_INSTALL")
.env_remove("PNPM_HOME")
.output()
.expect("run llman with plugin PATH")
}
#[test]
fn external_delegation_forwards_argv_verbatim() {
let plugins = TempDir::new().expect("plugin tempdir");
write_plugin(plugins.path(), "llman-fake-echo", echo_plugin_body());
let output = run_llman_with_plugin_path(plugins.path(), &["fake-echo", "--flag", "value"]);
assert!(
output.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("args=--flag value"),
"plugin argv not forwarded verbatim; stdout:\n{stdout}"
);
}
#[test]
fn external_delegation_injects_resolved_config_dir_env() {
let plugins = TempDir::new().expect("plugin tempdir");
let config = TempDir::new().expect("config tempdir");
write_plugin(plugins.path(), "llman-fake-echo", echo_plugin_body());
let config_arg = format!("--config-dir={}", config.path().display());
let output = run_llman_with_plugin_path(plugins.path(), &[&config_arg, "fake-echo"]);
assert!(
output.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let expected = format!("config={}", config.path().display());
assert!(
stdout.contains(&expected),
"child LLMAN_CONFIG_DIR not normalized to resolved config dir; stdout:\n{stdout}"
);
}
#[test]
fn external_delegation_propagates_plugin_exit_code() {
let plugins = TempDir::new().expect("plugin tempdir");
write_plugin(plugins.path(), "llman-fake-exit", "#!/bin/sh\nexit 3\n");
let output = run_llman_with_plugin_path(plugins.path(), &["fake-exit"]);
assert_eq!(
output.status.code(),
Some(3),
"plugin exit code must propagate"
);
}
#[test]
fn external_delegation_maps_signal_death_to_128_plus_signum() {
let plugins = TempDir::new().expect("plugin tempdir");
write_plugin(
plugins.path(),
"llman-fake-signal",
"#!/bin/sh\nkill -TERM $$\n",
);
let output = run_llman_with_plugin_path(plugins.path(), &["fake-signal"]);
assert_eq!(
output.status.code(),
Some(128 + 15),
"signal death (SIGTERM=15) must map to exit 143; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn external_not_found_reports_unrecognized_and_plugin_hint() {
let plugins = TempDir::new().expect("plugin tempdir");
write_plugin(plugins.path(), "llman-real-plugin", "#!/bin/sh\nexit 0\n");
let output = run_llman_with_plugin_path(plugins.path(), &["no-such-cmd"]);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unrecognized"),
"stderr must report unrecognized command; stderr:\n{stderr}"
);
assert!(
stderr.contains("llman-real-plugin"),
"stderr must list discovered plugins; stderr:\n{stderr}"
);
}
#[test]
fn external_not_found_without_plugins_degrades_gracefully() {
let plugins = TempDir::new().expect("plugin tempdir");
let output = run_llman_with_plugin_path(plugins.path(), &["no-such-cmd"]);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("unrecognized"));
assert!(
!stderr.contains("discovered llman-* commands"),
"empty discovery must omit the hint section; stderr:\n{stderr}"
);
}
#[test]
fn sdd_subcommand_delegates_to_llman_sdd_plugin() {
let plugins = TempDir::new().expect("plugin tempdir");
write_plugin(
plugins.path(),
"llman-sdd",
"#!/bin/sh\nprintf 'sdd-args=%s\\n' \"$*\"\nexit 7\n",
);
let output = run_llman_with_plugin_path(plugins.path(), &["sdd", "list", "--specs", "--json"]);
assert_eq!(
output.status.code(),
Some(7),
"plugin exit code must propagate; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("sdd-args=list --specs --json"),
"argv not forwarded to llman-sdd; stdout:\n{stdout}"
);
}
#[test]
fn sdd_plugin_resolves_from_bun_bin_when_dir_is_off_path() {
let home = TempDir::new().expect("home tempdir");
let bun_bin = home.path().join(".bun/bin");
fs::create_dir_all(&bun_bin).expect("create .bun/bin");
write_plugin(&bun_bin, "llman-sdd", "#!/bin/sh\nprintf 'bun-linked\\n'\n");
let empty_path_dir = TempDir::new().expect("plugin tempdir");
let output = run_llman_with_path_env(
empty_path_dir.path().to_str().expect("utf-8 tempdir path"),
home.path(),
&["sdd"],
);
assert!(
output.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("bun-linked"),
"off-PATH ~/.bun/bin plugin not resolved; stdout:\n{stdout}"
);
}
#[test]
fn sdd_not_found_suggests_migration_install() {
let plugins = TempDir::new().expect("plugin tempdir");
let output = run_llman_with_plugin_path(plugins.path(), &["sdd"]);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("sdd functionality has moved"),
"sdd not-found must include migration hint; stderr:\n{stderr}"
);
}
#[test]
fn non_sdd_not_found_omits_migration_hint() {
let plugins = TempDir::new().expect("plugin tempdir");
let output = run_llman_with_plugin_path(plugins.path(), &["no-such-cmd"]);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("sdd functionality has moved"),
"migration hint must not leak into other commands' errors; stderr:\n{stderr}"
);
}