1use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum PluginProtocol {
25 JsonStdio,
28 Http,
31}
32
33impl std::fmt::Display for PluginProtocol {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 PluginProtocol::JsonStdio => write!(f, "json-stdio"),
37 PluginProtocol::Http => write!(f, "http"),
38 }
39 }
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PluginManifest {
45 pub name: String,
47
48 #[serde(default = "default_version")]
50 pub version: String,
51
52 #[serde(default)]
56 pub command: Option<String>,
57
58 #[serde(default)]
60 pub args: Vec<String>,
61
62 pub protocol: PluginProtocol,
64
65 #[serde(default)]
67 pub deliver_url: Option<String>,
68
69 #[serde(default)]
71 pub auth_token_env: Option<String>,
72
73 #[serde(default = "default_capabilities")]
75 pub capabilities: Vec<String>,
76
77 #[serde(default)]
79 pub description: Option<String>,
80
81 #[serde(default = "default_timeout_secs")]
83 pub timeout_secs: u64,
84
85 #[serde(default)]
93 pub build_command: Option<String>,
94
95 #[serde(default)]
99 pub min_daemon_version: Option<String>,
100
101 #[serde(default)]
104 pub source_url: Option<String>,
105}
106
107fn default_version() -> String {
108 "0.1.0".to_string()
109}
110
111fn default_capabilities() -> Vec<String> {
112 vec!["deliver_question".to_string()]
113}
114
115fn default_timeout_secs() -> u64 {
116 30
117}
118
119#[derive(Debug, Clone)]
121pub struct DiscoveredPlugin {
122 pub manifest: PluginManifest,
124 pub plugin_dir: PathBuf,
126 pub source: PluginSource,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub enum PluginSource {
133 ProjectLocal,
135 UserGlobal,
137 InlineConfig,
139}
140
141impl std::fmt::Display for PluginSource {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 PluginSource::ProjectLocal => write!(f, "project"),
145 PluginSource::UserGlobal => write!(f, "global"),
146 PluginSource::InlineConfig => write!(f, "config"),
147 }
148 }
149}
150
151#[derive(Debug, thiserror::Error)]
153pub enum PluginError {
154 #[error("plugin manifest not found: {path}")]
155 ManifestNotFound { path: PathBuf },
156
157 #[error("invalid plugin manifest at {path}: {reason}")]
158 InvalidManifest { path: PathBuf, reason: String },
159
160 #[error("plugin '{name}' requires command for json-stdio protocol")]
161 MissingCommand { name: String },
162
163 #[error("plugin '{name}' requires deliver_url for http protocol")]
164 MissingDeliverUrl { name: String },
165
166 #[error("duplicate plugin name '{name}' — found in {first} and {second}")]
167 DuplicateName {
168 name: String,
169 first: String,
170 second: String,
171 },
172
173 #[error("I/O error: {0}")]
174 Io(#[from] std::io::Error),
175
176 #[error("plugin install failed: {0}")]
177 InstallFailed(String),
178}
179
180impl PluginManifest {
181 pub fn load(path: &Path) -> Result<Self, PluginError> {
183 if !path.exists() {
184 return Err(PluginError::ManifestNotFound {
185 path: path.to_path_buf(),
186 });
187 }
188 let content = std::fs::read_to_string(path)?;
189 let manifest: Self =
190 toml::from_str(&content).map_err(|e| PluginError::InvalidManifest {
191 path: path.to_path_buf(),
192 reason: e.to_string(),
193 })?;
194 manifest.validate()?;
195 Ok(manifest)
196 }
197
198 pub fn validate(&self) -> Result<(), PluginError> {
200 match self.protocol {
201 PluginProtocol::JsonStdio => {
202 if self.command.is_none() {
203 return Err(PluginError::MissingCommand {
204 name: self.name.clone(),
205 });
206 }
207 }
208 PluginProtocol::Http => {
209 if self.deliver_url.is_none() {
210 return Err(PluginError::MissingDeliverUrl {
211 name: self.name.clone(),
212 });
213 }
214 }
215 }
216 Ok(())
217 }
218}
219
220pub fn discover_plugins(project_root: &Path) -> Vec<DiscoveredPlugin> {
226 let mut plugins = Vec::new();
227
228 let project_dir = project_root.join(".ta").join("plugins").join("channels");
230 scan_plugin_dir(&project_dir, PluginSource::ProjectLocal, &mut plugins);
231
232 if let Some(config_dir) = dirs_config_dir() {
234 let global_dir = config_dir.join("ta").join("plugins").join("channels");
235 scan_plugin_dir(&global_dir, PluginSource::UserGlobal, &mut plugins);
236 }
237
238 plugins
239}
240
241fn scan_plugin_dir(dir: &Path, source: PluginSource, plugins: &mut Vec<DiscoveredPlugin>) {
243 if !dir.is_dir() {
244 return;
245 }
246
247 let entries = match std::fs::read_dir(dir) {
248 Ok(entries) => entries,
249 Err(e) => {
250 tracing::warn!(
251 dir = %dir.display(),
252 error = %e,
253 "Failed to read plugin directory"
254 );
255 return;
256 }
257 };
258
259 for entry in entries.flatten() {
260 let path = entry.path();
261 if !path.is_dir() {
262 continue;
263 }
264
265 let manifest_path = path.join("channel.toml");
266 if !manifest_path.exists() {
267 continue;
268 }
269
270 match PluginManifest::load(&manifest_path) {
271 Ok(manifest) => {
272 tracing::debug!(
273 plugin = %manifest.name,
274 protocol = %manifest.protocol,
275 source = %source,
276 "Discovered channel plugin"
277 );
278 plugins.push(DiscoveredPlugin {
279 manifest,
280 plugin_dir: path,
281 source: source.clone(),
282 });
283 }
284 Err(e) => {
285 tracing::warn!(
286 path = %manifest_path.display(),
287 error = %e,
288 "Skipping invalid channel plugin"
289 );
290 }
291 }
292 }
293}
294
295pub fn install_plugin(
300 source: &Path,
301 project_root: &Path,
302 global: bool,
303) -> Result<DiscoveredPlugin, PluginError> {
304 let manifest_path = source.join("channel.toml");
306 let manifest = PluginManifest::load(&manifest_path)?;
307
308 let target_base = if global {
310 dirs_config_dir()
311 .ok_or_else(|| {
312 PluginError::InstallFailed("cannot determine user config directory".into())
313 })?
314 .join("ta")
315 .join("plugins")
316 .join("channels")
317 } else {
318 project_root.join(".ta").join("plugins").join("channels")
319 };
320
321 let target_dir = target_base.join(&manifest.name);
322
323 std::fs::create_dir_all(&target_dir)?;
325
326 copy_dir_contents(source, &target_dir)?;
328
329 #[cfg(target_os = "macos")]
332 codesign_plugin_binaries(&target_dir);
333
334 let plugin_source = if global {
335 PluginSource::UserGlobal
336 } else {
337 PluginSource::ProjectLocal
338 };
339
340 Ok(DiscoveredPlugin {
341 manifest,
342 plugin_dir: target_dir,
343 source: plugin_source,
344 })
345}
346
347pub fn copy_dir_contents_public(src: &Path, dst: &Path) -> Result<(), PluginError> {
349 copy_dir_contents(src, dst)
350}
351
352fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), PluginError> {
354 for entry in std::fs::read_dir(src)? {
355 let entry = entry?;
356 let src_path = entry.path();
357 let dst_path = dst.join(entry.file_name());
358
359 if src_path.is_dir() {
360 std::fs::create_dir_all(&dst_path)?;
361 copy_dir_contents(&src_path, &dst_path)?;
362 } else {
363 std::fs::copy(&src_path, &dst_path)?;
364 }
365 }
366 Ok(())
367}
368
369#[cfg(target_os = "macos")]
376fn codesign_plugin_binaries(plugin_dir: &Path) {
377 use std::os::unix::fs::PermissionsExt;
378
379 let entries = match std::fs::read_dir(plugin_dir) {
380 Ok(e) => e,
381 Err(e) => {
382 tracing::warn!(
383 dir = %plugin_dir.display(),
384 error = %e,
385 "macOS codesign: could not read plugin directory"
386 );
387 return;
388 }
389 };
390
391 for entry in entries.flatten() {
392 let path = entry.path();
393 if !path.is_file() {
394 continue;
395 }
396 let Ok(meta) = path.metadata() else { continue };
398 let mode = meta.permissions().mode();
399 if mode & 0o111 == 0 {
400 continue;
401 }
402
403 let status = std::process::Command::new("codesign")
404 .args(["--force", "--sign", "-", path.to_str().unwrap_or("")])
405 .status();
406
407 match status {
408 Ok(s) if s.success() => {
409 tracing::debug!(path = %path.display(), "macOS: ad-hoc signed plugin binary");
410 }
411 Ok(s) => {
412 tracing::warn!(
413 path = %path.display(),
414 exit_code = ?s.code(),
415 "macOS: codesign returned non-zero; plugin may be blocked by GateKeeper"
416 );
417 }
418 Err(e) => {
419 tracing::warn!(
420 path = %path.display(),
421 error = %e,
422 "macOS: codesign not available; plugin may be blocked by GateKeeper. \
423 Install Xcode Command Line Tools: xcode-select --install"
424 );
425 }
426 }
427 }
428}
429
430fn dirs_config_dir() -> Option<PathBuf> {
432 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
435 return Some(PathBuf::from(xdg));
436 }
437 std::env::var("HOME")
438 .ok()
439 .map(|home| PathBuf::from(home).join(".config"))
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn parse_json_stdio_manifest() {
448 let toml_str = r#"
449name = "teams"
450version = "0.1.0"
451command = "python3 ta-channel-teams.py"
452protocol = "json-stdio"
453capabilities = ["deliver_question"]
454description = "Microsoft Teams channel plugin"
455"#;
456 let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
457 assert_eq!(manifest.name, "teams");
458 assert_eq!(manifest.protocol, PluginProtocol::JsonStdio);
459 assert_eq!(
460 manifest.command.as_deref(),
461 Some("python3 ta-channel-teams.py")
462 );
463 assert!(manifest.deliver_url.is_none());
464 assert!(manifest.validate().is_ok());
465 }
466
467 #[test]
468 fn parse_http_manifest() {
469 let toml_str = r#"
470name = "pagerduty"
471protocol = "http"
472deliver_url = "https://my-service.com/ta/deliver"
473auth_token_env = "TA_PAGERDUTY_TOKEN"
474"#;
475 let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
476 assert_eq!(manifest.name, "pagerduty");
477 assert_eq!(manifest.protocol, PluginProtocol::Http);
478 assert_eq!(
479 manifest.deliver_url.as_deref(),
480 Some("https://my-service.com/ta/deliver")
481 );
482 assert!(manifest.validate().is_ok());
483 }
484
485 #[test]
486 fn json_stdio_requires_command() {
487 let toml_str = r#"
488name = "broken"
489protocol = "json-stdio"
490"#;
491 let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
492 let err = manifest.validate().unwrap_err();
493 assert!(err.to_string().contains("requires command"));
494 }
495
496 #[test]
497 fn http_requires_deliver_url() {
498 let toml_str = r#"
499name = "broken"
500protocol = "http"
501"#;
502 let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
503 let err = manifest.validate().unwrap_err();
504 assert!(err.to_string().contains("requires deliver_url"));
505 }
506
507 #[test]
508 fn default_values() {
509 let toml_str = r#"
510name = "minimal"
511command = "my-plugin"
512protocol = "json-stdio"
513"#;
514 let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
515 assert_eq!(manifest.version, "0.1.0");
516 assert_eq!(manifest.capabilities, vec!["deliver_question"]);
517 assert_eq!(manifest.timeout_secs, 30);
518 assert!(manifest.args.is_empty());
519 }
520
521 #[test]
522 fn load_manifest_from_file() {
523 let dir = tempfile::tempdir().unwrap();
524 let manifest_path = dir.path().join("channel.toml");
525 std::fs::write(
526 &manifest_path,
527 r#"
528name = "test-plugin"
529command = "test-cmd"
530protocol = "json-stdio"
531"#,
532 )
533 .unwrap();
534
535 let manifest = PluginManifest::load(&manifest_path).unwrap();
536 assert_eq!(manifest.name, "test-plugin");
537 }
538
539 #[test]
540 fn load_manifest_not_found() {
541 let err = PluginManifest::load(Path::new("/nonexistent/channel.toml")).unwrap_err();
542 assert!(matches!(err, PluginError::ManifestNotFound { .. }));
543 }
544
545 #[test]
546 fn load_manifest_invalid_toml() {
547 let dir = tempfile::tempdir().unwrap();
548 let manifest_path = dir.path().join("channel.toml");
549 std::fs::write(&manifest_path, "this is not valid toml {{{").unwrap();
550
551 let err = PluginManifest::load(&manifest_path).unwrap_err();
552 assert!(matches!(err, PluginError::InvalidManifest { .. }));
553 }
554
555 #[test]
556 fn discover_plugins_in_directory() {
557 let dir = tempfile::tempdir().unwrap();
558 let plugins_dir = dir.path().join(".ta").join("plugins").join("channels");
559
560 let plugin1_dir = plugins_dir.join("teams");
562 std::fs::create_dir_all(&plugin1_dir).unwrap();
563 std::fs::write(
564 plugin1_dir.join("channel.toml"),
565 r#"
566name = "teams"
567command = "ta-channel-teams"
568protocol = "json-stdio"
569"#,
570 )
571 .unwrap();
572
573 let plugin2_dir = plugins_dir.join("pagerduty");
574 std::fs::create_dir_all(&plugin2_dir).unwrap();
575 std::fs::write(
576 plugin2_dir.join("channel.toml"),
577 r#"
578name = "pagerduty"
579protocol = "http"
580deliver_url = "https://example.com/deliver"
581"#,
582 )
583 .unwrap();
584
585 let plugins = discover_plugins(dir.path());
586 assert_eq!(plugins.len(), 2);
587
588 let names: Vec<&str> = plugins.iter().map(|p| p.manifest.name.as_str()).collect();
589 assert!(names.contains(&"teams"));
590 assert!(names.contains(&"pagerduty"));
591 }
592
593 #[test]
594 fn discover_plugins_skips_invalid() {
595 let dir = tempfile::tempdir().unwrap();
596 let plugins_dir = dir.path().join(".ta").join("plugins").join("channels");
597
598 let valid_dir = plugins_dir.join("good");
600 std::fs::create_dir_all(&valid_dir).unwrap();
601 std::fs::write(
602 valid_dir.join("channel.toml"),
603 r#"
604name = "good"
605command = "good-plugin"
606protocol = "json-stdio"
607"#,
608 )
609 .unwrap();
610
611 let bad_dir = plugins_dir.join("bad");
613 std::fs::create_dir_all(&bad_dir).unwrap();
614 std::fs::write(bad_dir.join("channel.toml"), "this is broken").unwrap();
615
616 let plugins = discover_plugins(dir.path());
617 assert_eq!(plugins.len(), 1);
618 assert_eq!(plugins[0].manifest.name, "good");
619 }
620
621 #[test]
622 fn discover_plugins_empty_dir() {
623 let dir = tempfile::tempdir().unwrap();
624 let plugins = discover_plugins(dir.path());
625 assert!(plugins.is_empty());
626 }
627
628 #[test]
629 fn install_plugin_to_project() {
630 let project = tempfile::tempdir().unwrap();
631 let source = tempfile::tempdir().unwrap();
632
633 std::fs::write(
635 source.path().join("channel.toml"),
636 r#"
637name = "my-plugin"
638command = "my-plugin-cmd"
639protocol = "json-stdio"
640"#,
641 )
642 .unwrap();
643 std::fs::write(source.path().join("my-plugin-cmd"), "#!/bin/bash\necho ok").unwrap();
644
645 let result = install_plugin(source.path(), project.path(), false).unwrap();
646 assert_eq!(result.manifest.name, "my-plugin");
647 assert_eq!(result.source, PluginSource::ProjectLocal);
648
649 let installed_manifest = project
651 .path()
652 .join(".ta/plugins/channels/my-plugin/channel.toml");
653 assert!(installed_manifest.exists());
654 }
655
656 #[test]
657 fn plugin_protocol_display() {
658 assert_eq!(format!("{}", PluginProtocol::JsonStdio), "json-stdio");
659 assert_eq!(format!("{}", PluginProtocol::Http), "http");
660 }
661
662 #[test]
663 fn plugin_source_display() {
664 assert_eq!(format!("{}", PluginSource::ProjectLocal), "project");
665 assert_eq!(format!("{}", PluginSource::UserGlobal), "global");
666 assert_eq!(format!("{}", PluginSource::InlineConfig), "config");
667 }
668
669 #[test]
670 fn manifest_with_args() {
671 let toml_str = r#"
672name = "python-plugin"
673command = "python3"
674args = ["-u", "channel_plugin.py"]
675protocol = "json-stdio"
676"#;
677 let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
678 assert_eq!(manifest.args, vec!["-u", "channel_plugin.py"]);
679 }
680
681 #[test]
682 fn manifest_with_build_command() {
683 let toml_str = r#"
684name = "go-plugin"
685command = "ta-channel-teams"
686protocol = "json-stdio"
687build_command = "go build -o ta-channel-teams ."
688"#;
689 let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
690 assert_eq!(
691 manifest.build_command.as_deref(),
692 Some("go build -o ta-channel-teams .")
693 );
694 }
695
696 #[test]
697 fn manifest_without_build_command() {
698 let toml_str = r#"
699name = "rust-plugin"
700command = "ta-channel-rust"
701protocol = "json-stdio"
702"#;
703 let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
704 assert!(manifest.build_command.is_none());
705 }
706
707 #[test]
708 fn plugin_error_display() {
709 let err = PluginError::MissingCommand {
710 name: "test".into(),
711 };
712 assert!(err.to_string().contains("test"));
713 assert!(err.to_string().contains("command"));
714
715 let err = PluginError::DuplicateName {
716 name: "dup".into(),
717 first: "project".into(),
718 second: "global".into(),
719 };
720 assert!(err.to_string().contains("dup"));
721 }
722}