use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PluginProtocol {
JsonStdio,
Http,
}
impl std::fmt::Display for PluginProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginProtocol::JsonStdio => write!(f, "json-stdio"),
PluginProtocol::Http => write!(f, "http"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub name: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
pub protocol: PluginProtocol,
#[serde(default)]
pub deliver_url: Option<String>,
#[serde(default)]
pub auth_token_env: Option<String>,
#[serde(default = "default_capabilities")]
pub capabilities: Vec<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub build_command: Option<String>,
#[serde(default)]
pub min_daemon_version: Option<String>,
#[serde(default)]
pub source_url: Option<String>,
}
fn default_version() -> String {
"0.1.0".to_string()
}
fn default_capabilities() -> Vec<String> {
vec!["deliver_question".to_string()]
}
fn default_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone)]
pub struct DiscoveredPlugin {
pub manifest: PluginManifest,
pub plugin_dir: PathBuf,
pub source: PluginSource,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PluginSource {
ProjectLocal,
UserGlobal,
InlineConfig,
}
impl std::fmt::Display for PluginSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginSource::ProjectLocal => write!(f, "project"),
PluginSource::UserGlobal => write!(f, "global"),
PluginSource::InlineConfig => write!(f, "config"),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
#[error("plugin manifest not found: {path}")]
ManifestNotFound { path: PathBuf },
#[error("invalid plugin manifest at {path}: {reason}")]
InvalidManifest { path: PathBuf, reason: String },
#[error("plugin '{name}' requires command for json-stdio protocol")]
MissingCommand { name: String },
#[error("plugin '{name}' requires deliver_url for http protocol")]
MissingDeliverUrl { name: String },
#[error("duplicate plugin name '{name}' — found in {first} and {second}")]
DuplicateName {
name: String,
first: String,
second: String,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("plugin install failed: {0}")]
InstallFailed(String),
}
impl PluginManifest {
pub fn load(path: &Path) -> Result<Self, PluginError> {
if !path.exists() {
return Err(PluginError::ManifestNotFound {
path: path.to_path_buf(),
});
}
let content = std::fs::read_to_string(path)?;
let manifest: Self =
toml::from_str(&content).map_err(|e| PluginError::InvalidManifest {
path: path.to_path_buf(),
reason: e.to_string(),
})?;
manifest.validate()?;
Ok(manifest)
}
pub fn validate(&self) -> Result<(), PluginError> {
match self.protocol {
PluginProtocol::JsonStdio => {
if self.command.is_none() {
return Err(PluginError::MissingCommand {
name: self.name.clone(),
});
}
}
PluginProtocol::Http => {
if self.deliver_url.is_none() {
return Err(PluginError::MissingDeliverUrl {
name: self.name.clone(),
});
}
}
}
Ok(())
}
}
pub fn discover_plugins(project_root: &Path) -> Vec<DiscoveredPlugin> {
let mut plugins = Vec::new();
let project_dir = project_root.join(".ta").join("plugins").join("channels");
scan_plugin_dir(&project_dir, PluginSource::ProjectLocal, &mut plugins);
if let Some(config_dir) = dirs_config_dir() {
let global_dir = config_dir.join("ta").join("plugins").join("channels");
scan_plugin_dir(&global_dir, PluginSource::UserGlobal, &mut plugins);
}
plugins
}
fn scan_plugin_dir(dir: &Path, source: PluginSource, plugins: &mut Vec<DiscoveredPlugin>) {
if !dir.is_dir() {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
tracing::warn!(
dir = %dir.display(),
error = %e,
"Failed to read plugin directory"
);
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("channel.toml");
if !manifest_path.exists() {
continue;
}
match PluginManifest::load(&manifest_path) {
Ok(manifest) => {
tracing::debug!(
plugin = %manifest.name,
protocol = %manifest.protocol,
source = %source,
"Discovered channel plugin"
);
plugins.push(DiscoveredPlugin {
manifest,
plugin_dir: path,
source: source.clone(),
});
}
Err(e) => {
tracing::warn!(
path = %manifest_path.display(),
error = %e,
"Skipping invalid channel plugin"
);
}
}
}
}
pub fn install_plugin(
source: &Path,
project_root: &Path,
global: bool,
) -> Result<DiscoveredPlugin, PluginError> {
let manifest_path = source.join("channel.toml");
let manifest = PluginManifest::load(&manifest_path)?;
let target_base = if global {
dirs_config_dir()
.ok_or_else(|| {
PluginError::InstallFailed("cannot determine user config directory".into())
})?
.join("ta")
.join("plugins")
.join("channels")
} else {
project_root.join(".ta").join("plugins").join("channels")
};
let target_dir = target_base.join(&manifest.name);
std::fs::create_dir_all(&target_dir)?;
copy_dir_contents(source, &target_dir)?;
#[cfg(target_os = "macos")]
codesign_plugin_binaries(&target_dir);
let plugin_source = if global {
PluginSource::UserGlobal
} else {
PluginSource::ProjectLocal
};
Ok(DiscoveredPlugin {
manifest,
plugin_dir: target_dir,
source: plugin_source,
})
}
pub fn copy_dir_contents_public(src: &Path, dst: &Path) -> Result<(), PluginError> {
copy_dir_contents(src, dst)
}
fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), PluginError> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
std::fs::create_dir_all(&dst_path)?;
copy_dir_contents(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[cfg(target_os = "macos")]
fn codesign_plugin_binaries(plugin_dir: &Path) {
use std::os::unix::fs::PermissionsExt;
let entries = match std::fs::read_dir(plugin_dir) {
Ok(e) => e,
Err(e) => {
tracing::warn!(
dir = %plugin_dir.display(),
error = %e,
"macOS codesign: could not read plugin directory"
);
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Ok(meta) = path.metadata() else { continue };
let mode = meta.permissions().mode();
if mode & 0o111 == 0 {
continue;
}
let status = std::process::Command::new("codesign")
.args(["--force", "--sign", "-", path.to_str().unwrap_or("")])
.status();
match status {
Ok(s) if s.success() => {
tracing::debug!(path = %path.display(), "macOS: ad-hoc signed plugin binary");
}
Ok(s) => {
tracing::warn!(
path = %path.display(),
exit_code = ?s.code(),
"macOS: codesign returned non-zero; plugin may be blocked by GateKeeper"
);
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"macOS: codesign not available; plugin may be blocked by GateKeeper. \
Install Xcode Command Line Tools: xcode-select --install"
);
}
}
}
}
fn dirs_config_dir() -> Option<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg));
}
std::env::var("HOME")
.ok()
.map(|home| PathBuf::from(home).join(".config"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_json_stdio_manifest() {
let toml_str = r#"
name = "teams"
version = "0.1.0"
command = "python3 ta-channel-teams.py"
protocol = "json-stdio"
capabilities = ["deliver_question"]
description = "Microsoft Teams channel plugin"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.name, "teams");
assert_eq!(manifest.protocol, PluginProtocol::JsonStdio);
assert_eq!(
manifest.command.as_deref(),
Some("python3 ta-channel-teams.py")
);
assert!(manifest.deliver_url.is_none());
assert!(manifest.validate().is_ok());
}
#[test]
fn parse_http_manifest() {
let toml_str = r#"
name = "pagerduty"
protocol = "http"
deliver_url = "https://my-service.com/ta/deliver"
auth_token_env = "TA_PAGERDUTY_TOKEN"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.name, "pagerduty");
assert_eq!(manifest.protocol, PluginProtocol::Http);
assert_eq!(
manifest.deliver_url.as_deref(),
Some("https://my-service.com/ta/deliver")
);
assert!(manifest.validate().is_ok());
}
#[test]
fn json_stdio_requires_command() {
let toml_str = r#"
name = "broken"
protocol = "json-stdio"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
let err = manifest.validate().unwrap_err();
assert!(err.to_string().contains("requires command"));
}
#[test]
fn http_requires_deliver_url() {
let toml_str = r#"
name = "broken"
protocol = "http"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
let err = manifest.validate().unwrap_err();
assert!(err.to_string().contains("requires deliver_url"));
}
#[test]
fn default_values() {
let toml_str = r#"
name = "minimal"
command = "my-plugin"
protocol = "json-stdio"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.version, "0.1.0");
assert_eq!(manifest.capabilities, vec!["deliver_question"]);
assert_eq!(manifest.timeout_secs, 30);
assert!(manifest.args.is_empty());
}
#[test]
fn load_manifest_from_file() {
let dir = tempfile::tempdir().unwrap();
let manifest_path = dir.path().join("channel.toml");
std::fs::write(
&manifest_path,
r#"
name = "test-plugin"
command = "test-cmd"
protocol = "json-stdio"
"#,
)
.unwrap();
let manifest = PluginManifest::load(&manifest_path).unwrap();
assert_eq!(manifest.name, "test-plugin");
}
#[test]
fn load_manifest_not_found() {
let err = PluginManifest::load(Path::new("/nonexistent/channel.toml")).unwrap_err();
assert!(matches!(err, PluginError::ManifestNotFound { .. }));
}
#[test]
fn load_manifest_invalid_toml() {
let dir = tempfile::tempdir().unwrap();
let manifest_path = dir.path().join("channel.toml");
std::fs::write(&manifest_path, "this is not valid toml {{{").unwrap();
let err = PluginManifest::load(&manifest_path).unwrap_err();
assert!(matches!(err, PluginError::InvalidManifest { .. }));
}
#[test]
fn discover_plugins_in_directory() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join(".ta").join("plugins").join("channels");
let plugin1_dir = plugins_dir.join("teams");
std::fs::create_dir_all(&plugin1_dir).unwrap();
std::fs::write(
plugin1_dir.join("channel.toml"),
r#"
name = "teams"
command = "ta-channel-teams"
protocol = "json-stdio"
"#,
)
.unwrap();
let plugin2_dir = plugins_dir.join("pagerduty");
std::fs::create_dir_all(&plugin2_dir).unwrap();
std::fs::write(
plugin2_dir.join("channel.toml"),
r#"
name = "pagerduty"
protocol = "http"
deliver_url = "https://example.com/deliver"
"#,
)
.unwrap();
let plugins = discover_plugins(dir.path());
assert_eq!(plugins.len(), 2);
let names: Vec<&str> = plugins.iter().map(|p| p.manifest.name.as_str()).collect();
assert!(names.contains(&"teams"));
assert!(names.contains(&"pagerduty"));
}
#[test]
fn discover_plugins_skips_invalid() {
let dir = tempfile::tempdir().unwrap();
let plugins_dir = dir.path().join(".ta").join("plugins").join("channels");
let valid_dir = plugins_dir.join("good");
std::fs::create_dir_all(&valid_dir).unwrap();
std::fs::write(
valid_dir.join("channel.toml"),
r#"
name = "good"
command = "good-plugin"
protocol = "json-stdio"
"#,
)
.unwrap();
let bad_dir = plugins_dir.join("bad");
std::fs::create_dir_all(&bad_dir).unwrap();
std::fs::write(bad_dir.join("channel.toml"), "this is broken").unwrap();
let plugins = discover_plugins(dir.path());
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].manifest.name, "good");
}
#[test]
fn discover_plugins_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let plugins = discover_plugins(dir.path());
assert!(plugins.is_empty());
}
#[test]
fn install_plugin_to_project() {
let project = tempfile::tempdir().unwrap();
let source = tempfile::tempdir().unwrap();
std::fs::write(
source.path().join("channel.toml"),
r#"
name = "my-plugin"
command = "my-plugin-cmd"
protocol = "json-stdio"
"#,
)
.unwrap();
std::fs::write(source.path().join("my-plugin-cmd"), "#!/bin/bash\necho ok").unwrap();
let result = install_plugin(source.path(), project.path(), false).unwrap();
assert_eq!(result.manifest.name, "my-plugin");
assert_eq!(result.source, PluginSource::ProjectLocal);
let installed_manifest = project
.path()
.join(".ta/plugins/channels/my-plugin/channel.toml");
assert!(installed_manifest.exists());
}
#[test]
fn plugin_protocol_display() {
assert_eq!(format!("{}", PluginProtocol::JsonStdio), "json-stdio");
assert_eq!(format!("{}", PluginProtocol::Http), "http");
}
#[test]
fn plugin_source_display() {
assert_eq!(format!("{}", PluginSource::ProjectLocal), "project");
assert_eq!(format!("{}", PluginSource::UserGlobal), "global");
assert_eq!(format!("{}", PluginSource::InlineConfig), "config");
}
#[test]
fn manifest_with_args() {
let toml_str = r#"
name = "python-plugin"
command = "python3"
args = ["-u", "channel_plugin.py"]
protocol = "json-stdio"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.args, vec!["-u", "channel_plugin.py"]);
}
#[test]
fn manifest_with_build_command() {
let toml_str = r#"
name = "go-plugin"
command = "ta-channel-teams"
protocol = "json-stdio"
build_command = "go build -o ta-channel-teams ."
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert_eq!(
manifest.build_command.as_deref(),
Some("go build -o ta-channel-teams .")
);
}
#[test]
fn manifest_without_build_command() {
let toml_str = r#"
name = "rust-plugin"
command = "ta-channel-rust"
protocol = "json-stdio"
"#;
let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.build_command.is_none());
}
#[test]
fn plugin_error_display() {
let err = PluginError::MissingCommand {
name: "test".into(),
};
assert!(err.to_string().contains("test"));
assert!(err.to_string().contains("command"));
let err = PluginError::DuplicateName {
name: "dup".into(),
first: "project".into(),
second: "global".into(),
};
assert!(err.to_string().contains("dup"));
}
}