1use std::path::{Path, PathBuf};
26
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct VcsPluginManifest {
36 pub name: String,
38
39 #[serde(default = "default_version")]
41 pub version: String,
42
43 #[serde(rename = "type", default = "default_type")]
45 pub plugin_type: String,
46
47 pub command: String,
51
52 #[serde(default)]
54 pub args: Vec<String>,
55
56 #[serde(default)]
63 pub capabilities: Vec<String>,
64
65 #[serde(default)]
67 pub description: Option<String>,
68
69 #[serde(default = "default_timeout_secs")]
71 pub timeout_secs: u64,
72
73 #[serde(default)]
75 pub min_daemon_version: Option<String>,
76
77 #[serde(default)]
79 pub source_url: Option<String>,
80
81 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
94 pub staging_env: std::collections::HashMap<String, String>,
95}
96
97fn default_version() -> String {
98 "0.1.0".to_string()
99}
100
101fn default_type() -> String {
102 "vcs".to_string()
103}
104
105fn default_timeout_secs() -> u64 {
106 30
107}
108
109impl VcsPluginManifest {
110 pub fn load(path: &Path) -> Result<Self, VcsPluginError> {
112 if !path.exists() {
113 return Err(VcsPluginError::ManifestNotFound {
114 path: path.to_path_buf(),
115 });
116 }
117 let content = std::fs::read_to_string(path)?;
118 let manifest: Self =
119 toml::from_str(&content).map_err(|e| VcsPluginError::InvalidManifest {
120 path: path.to_path_buf(),
121 reason: e.to_string(),
122 })?;
123 manifest.validate()?;
124 Ok(manifest)
125 }
126
127 pub fn validate(&self) -> Result<(), VcsPluginError> {
129 if self.plugin_type != "vcs" {
130 return Err(VcsPluginError::InvalidManifest {
131 path: PathBuf::from("<inline>"),
132 reason: format!("expected type = \"vcs\", got \"{}\"", self.plugin_type),
133 });
134 }
135 if self.command.trim().is_empty() {
136 return Err(VcsPluginError::MissingCommand {
137 name: self.name.clone(),
138 });
139 }
140 Ok(())
141 }
142
143 pub fn has_protected_targets(&self) -> bool {
145 self.capabilities.iter().any(|c| c == "protected_targets")
146 }
147}
148
149#[derive(Debug, thiserror::Error)]
155pub enum VcsPluginError {
156 #[error("plugin manifest not found: {path}")]
157 ManifestNotFound { path: PathBuf },
158
159 #[error("invalid plugin manifest at {path}: {reason}")]
160 InvalidManifest { path: PathBuf, reason: String },
161
162 #[error("plugin '{name}' requires 'command' field")]
163 MissingCommand { name: String },
164
165 #[error("duplicate VCS plugin name '{name}' — found in {first} and {second}")]
166 DuplicateName {
167 name: String,
168 first: String,
169 second: String,
170 },
171
172 #[error("I/O error: {0}")]
173 Io(#[from] std::io::Error),
174
175 #[error("plugin install failed: {0}")]
176 InstallFailed(String),
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub enum VcsPluginSource {
186 ProjectLocal,
188 UserGlobal,
190 Path,
192}
193
194impl std::fmt::Display for VcsPluginSource {
195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 match self {
197 VcsPluginSource::ProjectLocal => write!(f, "project"),
198 VcsPluginSource::UserGlobal => write!(f, "global"),
199 VcsPluginSource::Path => write!(f, "PATH"),
200 }
201 }
202}
203
204#[derive(Debug, Clone)]
206pub struct DiscoveredVcsPlugin {
207 pub manifest: VcsPluginManifest,
209 pub plugin_dir: Option<PathBuf>,
211 pub source: VcsPluginSource,
213}
214
215pub fn discover_vcs_plugins(project_root: &Path) -> Vec<DiscoveredVcsPlugin> {
228 let mut plugins = Vec::new();
229
230 let project_dir = project_root.join(".ta").join("plugins").join("vcs");
232 scan_vcs_plugin_dir(&project_dir, VcsPluginSource::ProjectLocal, &mut plugins);
233
234 if let Some(config_dir) = user_config_dir() {
236 let global_dir = config_dir.join("ta").join("plugins").join("vcs");
237 scan_vcs_plugin_dir(&global_dir, VcsPluginSource::UserGlobal, &mut plugins);
238 }
239
240 plugins
241}
242
243fn scan_vcs_plugin_dir(dir: &Path, source: VcsPluginSource, out: &mut Vec<DiscoveredVcsPlugin>) {
245 if !dir.is_dir() {
246 return;
247 }
248
249 let entries = match std::fs::read_dir(dir) {
250 Ok(e) => e,
251 Err(e) => {
252 tracing::warn!(
253 dir = %dir.display(),
254 error = %e,
255 "Failed to read VCS plugin directory"
256 );
257 return;
258 }
259 };
260
261 for entry in entries.flatten() {
262 let path = entry.path();
263 if !path.is_dir() {
264 continue;
265 }
266
267 let manifest_path = path.join("plugin.toml");
268 if !manifest_path.exists() {
269 continue;
270 }
271
272 match VcsPluginManifest::load(&manifest_path) {
273 Ok(manifest) => {
274 tracing::debug!(
275 plugin = %manifest.name,
276 source = %source,
277 "Discovered VCS plugin"
278 );
279 out.push(DiscoveredVcsPlugin {
280 manifest,
281 plugin_dir: Some(path),
282 source: source.clone(),
283 });
284 }
285 Err(e) => {
286 tracing::warn!(
287 path = %manifest_path.display(),
288 error = %e,
289 "Skipping invalid VCS plugin manifest"
290 );
291 }
292 }
293 }
294}
295
296pub fn find_vcs_plugin(adapter_name: &str, project_root: &Path) -> Option<DiscoveredVcsPlugin> {
301 let all = discover_vcs_plugins(project_root);
303 if let Some(p) = all.into_iter().find(|p| p.manifest.name == adapter_name) {
304 return Some(p);
305 }
306
307 let bare_cmd = format!("ta-submit-{}", adapter_name);
309 if which_on_path(&bare_cmd) {
310 tracing::info!(
311 adapter = %adapter_name,
312 command = %bare_cmd,
313 "Found VCS plugin as bare executable on PATH"
314 );
315 return Some(DiscoveredVcsPlugin {
316 manifest: VcsPluginManifest {
317 name: adapter_name.to_string(),
318 version: "unknown".to_string(),
319 plugin_type: "vcs".to_string(),
320 command: bare_cmd,
321 args: vec![],
322 capabilities: vec![],
323 description: None,
324 timeout_secs: 30,
325 min_daemon_version: None,
326 source_url: None,
327 staging_env: std::collections::HashMap::new(),
328 },
329 plugin_dir: None,
330 source: VcsPluginSource::Path,
331 });
332 }
333
334 None
335}
336
337fn which_on_path(name: &str) -> bool {
339 std::env::var_os("PATH")
340 .map(|path_var| std::env::split_paths(&path_var).any(|dir| dir.join(name).is_file()))
341 .unwrap_or(false)
342}
343
344fn user_config_dir() -> Option<PathBuf> {
346 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
347 return Some(PathBuf::from(xdg));
348 }
349 std::env::var("HOME")
350 .ok()
351 .map(|home| PathBuf::from(home).join(".config"))
352}
353
354#[cfg(test)]
359mod tests {
360 use super::*;
361
362 fn write_manifest(dir: &Path, content: &str) {
363 std::fs::write(dir.join("plugin.toml"), content).unwrap();
364 }
365
366 #[test]
367 fn load_valid_manifest() {
368 let dir = tempfile::tempdir().unwrap();
369 write_manifest(
370 dir.path(),
371 r#"
372name = "perforce"
373version = "0.1.0"
374type = "vcs"
375command = "ta-submit-perforce"
376protocol = "json-stdio"
377capabilities = ["commit", "push", "protected_targets"]
378description = "Perforce adapter"
379"#,
380 );
381 let manifest = VcsPluginManifest::load(&dir.path().join("plugin.toml")).unwrap();
382 assert_eq!(manifest.name, "perforce");
383 assert_eq!(manifest.version, "0.1.0");
384 assert!(manifest.has_protected_targets());
385 }
386
387 #[test]
388 fn load_manifest_missing() {
389 let err = VcsPluginManifest::load(Path::new("/nonexistent/plugin.toml")).unwrap_err();
390 assert!(matches!(err, VcsPluginError::ManifestNotFound { .. }));
391 }
392
393 #[test]
394 fn validate_wrong_type() {
395 let manifest = VcsPluginManifest {
396 name: "bad".to_string(),
397 version: "0.1.0".to_string(),
398 plugin_type: "channel".to_string(),
399 command: "some-cmd".to_string(),
400 args: vec![],
401 capabilities: vec![],
402 description: None,
403 timeout_secs: 30,
404 min_daemon_version: None,
405 source_url: None,
406 staging_env: std::collections::HashMap::new(),
407 };
408 let err = manifest.validate().unwrap_err();
409 assert!(err.to_string().contains("vcs"));
410 }
411
412 #[test]
413 fn validate_empty_command() {
414 let manifest = VcsPluginManifest {
415 name: "bad".to_string(),
416 version: "0.1.0".to_string(),
417 plugin_type: "vcs".to_string(),
418 command: " ".to_string(),
419 args: vec![],
420 capabilities: vec![],
421 description: None,
422 timeout_secs: 30,
423 min_daemon_version: None,
424 source_url: None,
425 staging_env: std::collections::HashMap::new(),
426 };
427 let err = manifest.validate().unwrap_err();
428 assert!(matches!(err, VcsPluginError::MissingCommand { .. }));
429 }
430
431 #[test]
432 fn has_protected_targets_true() {
433 let manifest = VcsPluginManifest {
434 name: "p4".to_string(),
435 version: "0.1.0".to_string(),
436 plugin_type: "vcs".to_string(),
437 command: "ta-submit-perforce".to_string(),
438 args: vec![],
439 capabilities: vec!["commit".to_string(), "protected_targets".to_string()],
440 description: None,
441 timeout_secs: 30,
442 min_daemon_version: None,
443 source_url: None,
444 staging_env: std::collections::HashMap::new(),
445 };
446 assert!(manifest.has_protected_targets());
447 }
448
449 #[test]
450 fn has_protected_targets_false() {
451 let manifest = VcsPluginManifest {
452 name: "custom".to_string(),
453 version: "0.1.0".to_string(),
454 plugin_type: "vcs".to_string(),
455 command: "ta-submit-custom".to_string(),
456 args: vec![],
457 capabilities: vec!["commit".to_string()],
458 description: None,
459 timeout_secs: 30,
460 min_daemon_version: None,
461 source_url: None,
462 staging_env: std::collections::HashMap::new(),
463 };
464 assert!(!manifest.has_protected_targets());
465 }
466
467 #[test]
468 fn discover_vcs_plugins_finds_manifests() {
469 let root = tempfile::tempdir().unwrap();
470 let vcs_dir = root.path().join(".ta").join("plugins").join("vcs");
471
472 let p4_dir = vcs_dir.join("perforce");
474 std::fs::create_dir_all(&p4_dir).unwrap();
475 write_manifest(
476 &p4_dir,
477 r#"
478name = "perforce"
479type = "vcs"
480command = "ta-submit-perforce"
481capabilities = ["commit", "protected_targets"]
482"#,
483 );
484
485 let plugins = discover_vcs_plugins(root.path());
486 assert_eq!(plugins.len(), 1);
487 assert_eq!(plugins[0].manifest.name, "perforce");
488 assert_eq!(plugins[0].source, VcsPluginSource::ProjectLocal);
489 }
490
491 #[test]
492 fn discover_vcs_plugins_skips_invalid() {
493 let root = tempfile::tempdir().unwrap();
494 let vcs_dir = root.path().join(".ta").join("plugins").join("vcs");
495
496 let good_dir = vcs_dir.join("good");
498 std::fs::create_dir_all(&good_dir).unwrap();
499 write_manifest(
500 &good_dir,
501 r#"name = "good"
502type = "vcs"
503command = "ta-submit-good"
504"#,
505 );
506
507 let bad_dir = vcs_dir.join("bad");
509 std::fs::create_dir_all(&bad_dir).unwrap();
510 std::fs::write(bad_dir.join("plugin.toml"), "{{not valid toml}}").unwrap();
511
512 let plugins = discover_vcs_plugins(root.path());
513 assert_eq!(plugins.len(), 1);
514 assert_eq!(plugins[0].manifest.name, "good");
515 }
516
517 #[test]
518 fn discover_vcs_plugins_empty_returns_empty() {
519 let root = tempfile::tempdir().unwrap();
520 let plugins = discover_vcs_plugins(root.path());
521 assert!(plugins.is_empty());
522 }
523
524 #[test]
525 fn vcs_plugin_source_display() {
526 assert_eq!(format!("{}", VcsPluginSource::ProjectLocal), "project");
527 assert_eq!(format!("{}", VcsPluginSource::UserGlobal), "global");
528 assert_eq!(format!("{}", VcsPluginSource::Path), "PATH");
529 }
530
531 #[test]
532 fn default_timeout_is_30() {
533 let dir = tempfile::tempdir().unwrap();
534 write_manifest(
535 dir.path(),
536 r#"name = "minimal"
537type = "vcs"
538command = "ta-submit-minimal"
539"#,
540 );
541 let manifest = VcsPluginManifest::load(&dir.path().join("plugin.toml")).unwrap();
542 assert_eq!(manifest.timeout_secs, 30);
543 }
544}