Skip to main content

ta_submit/
vcs_plugin_manifest.rs

1//! VCS adapter plugin manifest (`plugin.toml`) and discovery.
2//!
3//! VCS adapter plugins are external executables that implement the
4//! [`VcsPluginRequest`] / [`VcsPluginResponse`] JSON-over-stdio protocol.
5//!
6//! ## Plugin directories (searched in order)
7//!
8//! 1. `.ta/plugins/vcs/<name>/` — project-local
9//! 2. `~/.config/ta/plugins/vcs/<name>/` — user-global
10//! 3. `$PATH` — bare executable `ta-submit-<name>` (no manifest required)
11//!
12//! ## Manifest format (`plugin.toml`)
13//!
14//! ```toml
15//! name = "perforce"
16//! version = "0.1.0"
17//! type = "vcs"
18//! command = "ta-submit-perforce"
19//! protocol = "json-stdio"
20//! capabilities = ["commit", "push", "review", "protected_targets"]
21//! description = "Perforce / Helix Core VCS adapter"
22//! timeout_secs = 30
23//! ```
24
25use std::path::{Path, PathBuf};
26
27use serde::{Deserialize, Serialize};
28
29// ---------------------------------------------------------------------------
30// Manifest
31// ---------------------------------------------------------------------------
32
33/// Parsed `plugin.toml` manifest for a VCS adapter plugin.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct VcsPluginManifest {
36    /// Adapter name (e.g., "perforce", "svn").  Must be unique.
37    pub name: String,
38
39    /// Plugin version (semver).
40    #[serde(default = "default_version")]
41    pub version: String,
42
43    /// Plugin type — must be `"vcs"` for VCS plugins.
44    #[serde(rename = "type", default = "default_type")]
45    pub plugin_type: String,
46
47    /// Executable command to spawn for json-stdio protocol.
48    ///
49    /// Bare name (resolved via PATH) or an absolute path.  Required.
50    pub command: String,
51
52    /// Additional arguments passed to the command on every invocation.
53    #[serde(default)]
54    pub args: Vec<String>,
55
56    /// Capabilities this plugin exposes.
57    ///
58    /// Standard values: `"commit"`, `"push"`, `"review"`, `"sync"`,
59    /// `"save_state"`, `"check_review"`, `"merge_review"`, `"protected_targets"`.
60    ///
61    /// Plugins claiming `"protected_targets"` signal §15 compliance.
62    #[serde(default)]
63    pub capabilities: Vec<String>,
64
65    /// Human-readable description.
66    #[serde(default)]
67    pub description: Option<String>,
68
69    /// Per-call timeout in seconds.
70    #[serde(default = "default_timeout_secs")]
71    pub timeout_secs: u64,
72
73    /// Minimum TA daemon version required by this plugin.
74    #[serde(default)]
75    pub min_daemon_version: Option<String>,
76
77    /// Source URL for remote install / upgrade.
78    #[serde(default)]
79    pub source_url: Option<String>,
80
81    /// Static environment variable overrides to inject when TA runs an agent
82    /// for this VCS system (v0.13.17.3).
83    ///
84    /// These vars are merged into the agent's process environment before spawn.
85    /// Useful for VCS tools that use well-known env vars for client/workspace
86    /// selection (e.g., `P4CLIENT`, `SVN_SSH`, `HG_PLAIN`).
87    ///
88    /// ```toml
89    /// [staging_env]
90    /// P4CLIENT = ""           # clear live workspace
91    /// SVN_SSH = "ssh -q"      # quiet mode for SVN+SSH
92    /// ```
93    #[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    /// Load a manifest from a `plugin.toml` file.
111    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    /// Validate internal consistency.
128    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    /// Whether this plugin declares §15 protected-targets support.
144    pub fn has_protected_targets(&self) -> bool {
145        self.capabilities.iter().any(|c| c == "protected_targets")
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Errors
151// ---------------------------------------------------------------------------
152
153/// Errors from VCS plugin operations.
154#[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// ---------------------------------------------------------------------------
180// Discovered plugin
181// ---------------------------------------------------------------------------
182
183/// Where a plugin was discovered from.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub enum VcsPluginSource {
186    /// `.ta/plugins/vcs/` in the project root.
187    ProjectLocal,
188    /// `~/.config/ta/plugins/vcs/` (user-global).
189    UserGlobal,
190    /// Bare executable on `$PATH` (no manifest directory).
191    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/// A discovered VCS plugin with its manifest and origin.
205#[derive(Debug, Clone)]
206pub struct DiscoveredVcsPlugin {
207    /// Parsed manifest.
208    pub manifest: VcsPluginManifest,
209    /// Directory containing `plugin.toml` (None for PATH-discovered plugins).
210    pub plugin_dir: Option<PathBuf>,
211    /// Discovery source.
212    pub source: VcsPluginSource,
213}
214
215// ---------------------------------------------------------------------------
216// Discovery
217// ---------------------------------------------------------------------------
218
219/// Discover all VCS adapter plugins for the given project root.
220///
221/// Resolution order:
222/// 1. `.ta/plugins/vcs/` — project-local (highest priority)
223/// 2. `~/.config/ta/plugins/vcs/` — user-global
224///
225/// PATH discovery (`ta-submit-<name>`) is performed on-demand in the registry
226/// (see `crate::registry`) when a named adapter is not found in the above dirs.
227pub fn discover_vcs_plugins(project_root: &Path) -> Vec<DiscoveredVcsPlugin> {
228    let mut plugins = Vec::new();
229
230    // 1. Project-local
231    let project_dir = project_root.join(".ta").join("plugins").join("vcs");
232    scan_vcs_plugin_dir(&project_dir, VcsPluginSource::ProjectLocal, &mut plugins);
233
234    // 2. User-global
235    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
243/// Scan a directory for VCS plugin subdirectories containing `plugin.toml`.
244fn 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
296/// Find a VCS plugin by adapter name, searching project-local then user-global.
297///
298/// If no manifest-based plugin is found, synthesizes a minimal manifest for a
299/// bare `ta-submit-<name>` executable on `$PATH`.
300pub fn find_vcs_plugin(adapter_name: &str, project_root: &Path) -> Option<DiscoveredVcsPlugin> {
301    // Search manifest-based plugins.
302    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    // Fall back to bare PATH executable: `ta-submit-<name>`.
308    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
337/// Check whether a binary exists on PATH.
338fn 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
344/// Get the user's config directory.
345fn 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// ---------------------------------------------------------------------------
355// Tests
356// ---------------------------------------------------------------------------
357
358#[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        // Create a valid plugin
473        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        // Valid
497        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        // Invalid (bad TOML)
508        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}