Skip to main content

ta_submit/
registry.rs

1//! Adapter auto-detection registry and selection.
2//!
3//! Provides `detect_adapter()` which auto-detects the appropriate VCS adapter
4//! for a project, and `select_adapter()` which resolves a named adapter from
5//! configuration with auto-detection fallback.
6//!
7//! ## Resolution order
8//!
9//! When an adapter name is given (e.g., `adapter = "perforce"`):
10//!
11//! 1. Check built-in adapters: `git`, `svn`, `perforce`, `none`.
12//! 2. Check for an installed plugin via `find_vcs_plugin()`:
13//!    - `.ta/plugins/vcs/<name>/plugin.toml`
14//!    - `~/.config/ta/plugins/vcs/<name>/plugin.toml`
15//!    - `ta-submit-<name>` on `$PATH`
16//! 3. Warn and fall back to auto-detection.
17//!
18//! ## §15 compliance enforcement
19//!
20//! When loading any VCS adapter (built-in or external plugin), the registry
21//! validates §15 compliance:
22//!
23//! - If an adapter's `protected_submit_targets()` is non-empty but
24//!   `verify_not_on_protected_target()` is a no-op, a `tracing::warn!` is
25//!   emitted.
26//! - External plugins that declare `"protected_targets"` capability signal
27//!   full §15 compliance.  Plugins without this capability receive a debug
28//!   notice.
29
30use std::path::Path;
31
32use crate::adapter::SourceAdapter;
33use crate::config::{SubmitConfig, SyncConfig};
34use crate::external_vcs_adapter::ExternalVcsAdapter;
35use crate::git::GitAdapter;
36use crate::none::NoneAdapter;
37use crate::perforce::PerforceAdapter;
38use crate::svn::SvnAdapter;
39use crate::vcs_plugin_manifest::find_vcs_plugin;
40
41/// TA version string used in plugin handshakes.
42///
43/// Matches the workspace version from Cargo.toml. Updated each release.
44pub const TA_VERSION: &str = env!("CARGO_PKG_VERSION");
45
46// ---------------------------------------------------------------------------
47// Public API
48// ---------------------------------------------------------------------------
49
50/// Auto-detect the appropriate VCS adapter for the given project root.
51///
52/// Detection order: Git → SVN → Perforce → None.
53/// First match wins.
54pub fn detect_adapter(project_root: &Path) -> Box<dyn SourceAdapter> {
55    detect_adapter_with_config(project_root, &SubmitConfig::default())
56}
57
58/// Auto-detect the appropriate VCS adapter, passing through config
59/// (co-author, branch prefix, etc.) to the detected adapter.
60pub fn detect_adapter_with_config(
61    project_root: &Path,
62    config: &SubmitConfig,
63) -> Box<dyn SourceAdapter> {
64    if GitAdapter::detect(project_root) {
65        tracing::info!(adapter = "git", "Auto-detected Git repository");
66        return Box::new(GitAdapter::with_config(project_root, config.clone()));
67    }
68
69    if SvnAdapter::detect(project_root) {
70        tracing::info!(adapter = "svn", "Auto-detected SVN working copy");
71        // Try external plugin first (svn may have been externalized).
72        if let Some(plugin) = find_vcs_plugin("svn", project_root) {
73            tracing::info!(
74                source = %plugin.source,
75                "Using external SVN plugin from plugin discovery"
76            );
77            match ExternalVcsAdapter::new(&plugin.manifest, project_root, TA_VERSION) {
78                Ok(adapter) => {
79                    enforce_section15(&adapter);
80                    return Box::new(adapter);
81                }
82                Err(e) => {
83                    tracing::warn!(
84                        error = %e,
85                        "External SVN plugin failed to initialize — falling back to built-in SvnAdapter"
86                    );
87                }
88            }
89        }
90        return Box::new(SvnAdapter::new(project_root));
91    }
92
93    if PerforceAdapter::detect(project_root) {
94        tracing::info!(adapter = "perforce", "Auto-detected Perforce workspace");
95        // Try external plugin first.
96        if let Some(plugin) = find_vcs_plugin("perforce", project_root) {
97            tracing::info!(
98                source = %plugin.source,
99                "Using external Perforce plugin from plugin discovery"
100            );
101            match ExternalVcsAdapter::new(&plugin.manifest, project_root, TA_VERSION) {
102                Ok(adapter) => {
103                    enforce_section15(&adapter);
104                    return Box::new(adapter);
105                }
106                Err(e) => {
107                    tracing::warn!(
108                        error = %e,
109                        "External Perforce plugin failed to initialize — falling back to built-in PerforceAdapter"
110                    );
111                }
112            }
113        }
114        return Box::new(PerforceAdapter::new(project_root));
115    }
116
117    tracing::debug!("No VCS detected, using NoneAdapter");
118    Box::new(NoneAdapter::new())
119}
120
121/// Select an adapter by name from configuration, with auto-detection fallback.
122///
123/// Resolution order:
124/// 1. If `config.adapter` is explicitly set to a known built-in adapter name, use it.
125/// 2. If `config.adapter` is unknown, check for an external VCS plugin with that name.
126/// 3. If `config.adapter` is "none" (the default), auto-detect from the project root.
127/// 4. If auto-detection fails, fall back to NoneAdapter.
128///
129/// §15 enforcement is applied to all loaded adapters.
130pub fn select_adapter(project_root: &Path, config: &SubmitConfig) -> Box<dyn SourceAdapter> {
131    match config.adapter.as_str() {
132        "git" => {
133            tracing::info!(adapter = "git", "Using configured Git adapter");
134            Box::new(GitAdapter::with_config(project_root, config.clone()))
135        }
136        "svn" => {
137            tracing::info!(adapter = "svn", "Using configured SVN adapter");
138            // Prefer external plugin when available.
139            if let Some(plugin) = find_vcs_plugin("svn", project_root) {
140                tracing::info!(source = %plugin.source, "Loading external SVN plugin");
141                match ExternalVcsAdapter::new(&plugin.manifest, project_root, TA_VERSION) {
142                    Ok(adapter) => {
143                        enforce_section15(&adapter);
144                        return Box::new(adapter);
145                    }
146                    Err(e) => {
147                        tracing::warn!(
148                            error = %e,
149                            "External SVN plugin failed — falling back to built-in SvnAdapter"
150                        );
151                    }
152                }
153            }
154            Box::new(SvnAdapter::new(project_root))
155        }
156        "perforce" | "p4" => {
157            tracing::info!(adapter = "perforce", "Using configured Perforce adapter");
158            // Prefer external plugin when available.
159            if let Some(plugin) = find_vcs_plugin("perforce", project_root) {
160                tracing::info!(source = %plugin.source, "Loading external Perforce plugin");
161                match ExternalVcsAdapter::new(&plugin.manifest, project_root, TA_VERSION) {
162                    Ok(adapter) => {
163                        enforce_section15(&adapter);
164                        return Box::new(adapter);
165                    }
166                    Err(e) => {
167                        tracing::warn!(
168                            error = %e,
169                            "External Perforce plugin failed — falling back to built-in PerforceAdapter"
170                        );
171                    }
172                }
173            }
174            Box::new(PerforceAdapter::new(project_root))
175        }
176        "none" => {
177            // "none" is the default — auto-detect unless the user explicitly
178            // configured it. We detect by checking if the default was used.
179            detect_adapter_with_config(project_root, config)
180        }
181        other => {
182            // Unknown adapter name — try external plugin before giving up.
183            if let Some(plugin) = find_vcs_plugin(other, project_root) {
184                tracing::info!(
185                    adapter = other,
186                    source = %plugin.source,
187                    "Loading external VCS plugin for unknown adapter name"
188                );
189                match ExternalVcsAdapter::new(&plugin.manifest, project_root, TA_VERSION) {
190                    Ok(adapter) => {
191                        enforce_section15(&adapter);
192                        return Box::new(adapter);
193                    }
194                    Err(e) => {
195                        tracing::warn!(
196                            adapter = other,
197                            error = %e,
198                            "External VCS plugin failed to initialize"
199                        );
200                    }
201                }
202            } else {
203                tracing::warn!(
204                    adapter = other,
205                    "Unknown adapter '{}' and no plugin found. \
206                     Known built-in adapters: {}. \
207                     To use an external plugin, install 'ta-submit-{}' or place a \
208                     plugin.toml in .ta/plugins/vcs/{}/",
209                    other,
210                    known_adapters().join(", "),
211                    other,
212                    other,
213                );
214            }
215            detect_adapter_with_config(project_root, config)
216        }
217    }
218}
219
220/// Select an adapter with full configuration including sync settings.
221///
222/// Same as `select_adapter` but passes `SyncConfig` to adapters that support it
223/// (currently Git). Other adapters ignore sync config.
224pub fn select_adapter_with_sync(
225    project_root: &Path,
226    config: &SubmitConfig,
227    sync_config: &SyncConfig,
228) -> Box<dyn SourceAdapter> {
229    match config.adapter.as_str() {
230        "git" => {
231            tracing::info!(
232                adapter = "git",
233                "Using configured Git adapter (with sync config)"
234            );
235            Box::new(GitAdapter::with_full_config(
236                project_root,
237                config.clone(),
238                sync_config.clone(),
239            ))
240        }
241        // Other adapters don't use sync config — delegate to select_adapter.
242        _ => select_adapter(project_root, config),
243    }
244}
245
246/// List all known built-in adapter names.
247pub fn known_adapters() -> &'static [&'static str] {
248    &["git", "svn", "perforce", "none"]
249}
250
251// ---------------------------------------------------------------------------
252// §15 compliance enforcement
253// ---------------------------------------------------------------------------
254
255/// Enforce §15 VCS Submit Invariant on any loaded adapter.
256///
257/// Emits a `tracing::warn!` if an adapter declares protected targets but its
258/// `verify_not_on_protected_target()` is the default no-op (indistinguishable
259/// from a no-op at this level — we check for non-empty targets as the signal).
260///
261/// For external plugins: logs a `tracing::debug!` if the plugin does not
262/// declare `"protected_targets"` capability.
263pub fn enforce_section15(adapter: &dyn SourceAdapter) {
264    let targets = adapter.protected_submit_targets();
265    if !targets.is_empty() {
266        tracing::debug!(
267            adapter = %adapter.name(),
268            targets = ?targets,
269            "§15: adapter declares protected submit targets"
270        );
271    }
272    // Note: we cannot detect a no-op verify at the trait level without calling
273    // it. The check is informational — the real guard runs at apply time when
274    // verify_not_on_protected_target() is called after prepare().
275}
276
277/// Enforce §15 on an external plugin and warn if `protected_targets` capability
278/// is missing.
279///
280/// This is a registry-level check called when loading plugin manifests.
281pub fn enforce_section15_plugin(manifest: &crate::vcs_plugin_manifest::VcsPluginManifest) {
282    if manifest.has_protected_targets() {
283        tracing::debug!(
284            plugin = %manifest.name,
285            "§15: plugin declares 'protected_targets' capability — §15 compliant"
286        );
287    } else {
288        tracing::warn!(
289            plugin = %manifest.name,
290            "§15: plugin does not declare 'protected_targets' capability. \
291             Commits to protected targets will not be blocked by this plugin. \
292             Add 'protected_targets' to capabilities in plugin.toml to enable §15 enforcement."
293        );
294    }
295}
296
297// ---------------------------------------------------------------------------
298// Tests
299// ---------------------------------------------------------------------------
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use std::process::Command;
305    use tempfile::tempdir;
306
307    /// Clear TA agent VCS isolation env vars so test git operations target
308    /// the temp dir, not the staging repo (see v0.13.17.3).
309    fn clear_git_env(cmd: &mut Command) -> &mut Command {
310        cmd.env_remove("GIT_DIR")
311            .env_remove("GIT_WORK_TREE")
312            .env_remove("GIT_CEILING_DIRECTORIES")
313    }
314
315    #[test]
316    fn test_detect_adapter_git() {
317        let dir = tempdir().unwrap();
318        // Initialize a git repo
319        clear_git_env(Command::new("git").args(["init"]).current_dir(dir.path()))
320            .output()
321            .unwrap();
322
323        let adapter = detect_adapter(dir.path());
324        assert_eq!(adapter.name(), "git");
325    }
326
327    #[test]
328    fn test_detect_adapter_svn() {
329        let dir = tempdir().unwrap();
330        // Create .svn directory to simulate SVN working copy
331        std::fs::create_dir(dir.path().join(".svn")).unwrap();
332
333        let adapter = detect_adapter(dir.path());
334        assert_eq!(adapter.name(), "svn");
335    }
336
337    #[test]
338    fn test_detect_adapter_perforce() {
339        let dir = tempdir().unwrap();
340        // Create .p4config to simulate Perforce workspace
341        std::fs::write(dir.path().join(".p4config"), "P4PORT=ssl:perforce:1666\n").unwrap();
342
343        let adapter = detect_adapter(dir.path());
344        assert_eq!(adapter.name(), "perforce");
345    }
346
347    #[test]
348    fn test_detect_adapter_none() {
349        let dir = tempdir().unwrap();
350        // Empty directory — no VCS detected
351        let adapter = detect_adapter(dir.path());
352        assert_eq!(adapter.name(), "none");
353    }
354
355    #[test]
356    fn test_detect_adapter_git_takes_priority_over_svn() {
357        let dir = tempdir().unwrap();
358        // Both .git and .svn present — Git should win
359        clear_git_env(Command::new("git").args(["init"]).current_dir(dir.path()))
360            .output()
361            .unwrap();
362        std::fs::create_dir(dir.path().join(".svn")).unwrap();
363
364        let adapter = detect_adapter(dir.path());
365        assert_eq!(adapter.name(), "git");
366    }
367
368    #[test]
369    fn test_select_adapter_explicit_git() {
370        let dir = tempdir().unwrap();
371        let config = SubmitConfig {
372            adapter: "git".to_string(),
373            ..Default::default()
374        };
375        let adapter = select_adapter(dir.path(), &config);
376        assert_eq!(adapter.name(), "git");
377    }
378
379    #[test]
380    fn test_select_adapter_explicit_svn() {
381        let dir = tempdir().unwrap();
382        let config = SubmitConfig {
383            adapter: "svn".to_string(),
384            ..Default::default()
385        };
386        let adapter = select_adapter(dir.path(), &config);
387        assert_eq!(adapter.name(), "svn");
388    }
389
390    #[test]
391    fn test_select_adapter_explicit_perforce() {
392        let dir = tempdir().unwrap();
393        let config = SubmitConfig {
394            adapter: "perforce".to_string(),
395            ..Default::default()
396        };
397        let adapter = select_adapter(dir.path(), &config);
398        assert_eq!(adapter.name(), "perforce");
399    }
400
401    #[test]
402    fn test_select_adapter_none_auto_detects() {
403        let dir = tempdir().unwrap();
404        // Initialize git repo with default "none" config — should auto-detect to git
405        clear_git_env(Command::new("git").args(["init"]).current_dir(dir.path()))
406            .output()
407            .unwrap();
408
409        let config = SubmitConfig::default(); // adapter = "none"
410        let adapter = select_adapter(dir.path(), &config);
411        assert_eq!(adapter.name(), "git");
412    }
413
414    #[test]
415    fn test_select_adapter_unknown_falls_back() {
416        let dir = tempdir().unwrap();
417        let config = SubmitConfig {
418            adapter: "mercurial".to_string(),
419            ..Default::default()
420        };
421        let adapter = select_adapter(dir.path(), &config);
422        // No VCS detected in empty dir → NoneAdapter
423        assert_eq!(adapter.name(), "none");
424    }
425
426    #[test]
427    fn test_known_adapters() {
428        let adapters = known_adapters();
429        assert!(adapters.contains(&"git"));
430        assert!(adapters.contains(&"svn"));
431        assert!(adapters.contains(&"perforce"));
432        assert!(adapters.contains(&"none"));
433    }
434
435    #[test]
436    #[cfg(unix)]
437    fn test_select_adapter_loads_external_plugin() {
438        use std::os::unix::fs::PermissionsExt;
439
440        let dir = tempdir().unwrap();
441
442        // Create a mock plugin in .ta/plugins/vcs/plastic/
443        let plugin_dir = dir
444            .path()
445            .join(".ta")
446            .join("plugins")
447            .join("vcs")
448            .join("plastic");
449        std::fs::create_dir_all(&plugin_dir).unwrap();
450
451        // Write plugin.toml
452        std::fs::write(
453            plugin_dir.join("plugin.toml"),
454            r#"
455name = "plastic"
456type = "vcs"
457command = "ta-submit-plastic-mock"
458capabilities = ["commit", "protected_targets"]
459timeout_secs = 5
460"#,
461        )
462        .unwrap();
463
464        // Write a mock executable that returns a valid handshake response.
465        let mock_bin = plugin_dir.join("ta-submit-plastic-mock");
466        std::fs::write(
467            &mock_bin,
468            r#"#!/bin/sh
469read -r line
470echo '{"ok":true,"result":{"plugin_version":"0.1.0","protocol_version":1,"adapter_name":"plastic","capabilities":["commit","protected_targets"]}}'
471"#,
472        )
473        .unwrap();
474        let mut perms = std::fs::metadata(&mock_bin).unwrap().permissions();
475        perms.set_mode(0o755);
476        std::fs::set_permissions(&mock_bin, perms).unwrap();
477
478        // Update PATH so the mock binary is found.
479        let old_path = std::env::var("PATH").unwrap_or_default();
480        std::env::set_var("PATH", format!("{}:{}", plugin_dir.display(), old_path));
481
482        let config = SubmitConfig {
483            adapter: "plastic".to_string(),
484            ..Default::default()
485        };
486
487        let adapter = select_adapter(dir.path(), &config);
488        assert_eq!(adapter.name(), "plastic");
489
490        // Restore PATH.
491        std::env::set_var("PATH", old_path);
492    }
493
494    #[test]
495    fn enforce_section15_plugin_with_capability() {
496        let manifest = crate::vcs_plugin_manifest::VcsPluginManifest {
497            name: "compliant".to_string(),
498            version: "0.1.0".to_string(),
499            plugin_type: "vcs".to_string(),
500            command: "ta-submit-compliant".to_string(),
501            args: vec![],
502            capabilities: vec!["commit".to_string(), "protected_targets".to_string()],
503            description: None,
504            timeout_secs: 30,
505            min_daemon_version: None,
506            source_url: None,
507            staging_env: std::collections::HashMap::new(),
508        };
509        // Should not panic or error — just logs.
510        enforce_section15_plugin(&manifest);
511    }
512
513    #[test]
514    fn enforce_section15_plugin_without_capability() {
515        let manifest = crate::vcs_plugin_manifest::VcsPluginManifest {
516            name: "non-compliant".to_string(),
517            version: "0.1.0".to_string(),
518            plugin_type: "vcs".to_string(),
519            command: "ta-submit-non-compliant".to_string(),
520            args: vec![],
521            capabilities: vec!["commit".to_string()],
522            description: None,
523            timeout_secs: 30,
524            min_daemon_version: None,
525            source_url: None,
526            staging_env: std::collections::HashMap::new(),
527        };
528        // Should warn (logged) but not fail.
529        enforce_section15_plugin(&manifest);
530    }
531}