1use 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
41pub const TA_VERSION: &str = env!("CARGO_PKG_VERSION");
45
46pub fn detect_adapter(project_root: &Path) -> Box<dyn SourceAdapter> {
55 detect_adapter_with_config(project_root, &SubmitConfig::default())
56}
57
58pub 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 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 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
121pub 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 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 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 detect_adapter_with_config(project_root, config)
180 }
181 other => {
182 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
220pub 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 _ => select_adapter(project_root, config),
243 }
244}
245
246pub fn known_adapters() -> &'static [&'static str] {
248 &["git", "svn", "perforce", "none"]
249}
250
251pub 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 }
276
277pub 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#[cfg(test)]
302mod tests {
303 use super::*;
304 use std::process::Command;
305 use tempfile::tempdir;
306
307 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 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 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 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 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 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 clear_git_env(Command::new("git").args(["init"]).current_dir(dir.path()))
406 .output()
407 .unwrap();
408
409 let config = SubmitConfig::default(); 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 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 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 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 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 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 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 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 enforce_section15_plugin(&manifest);
530 }
531}