1use std::path::{Path, PathBuf};
11
12use sha2::{Digest, Sha256};
13
14use crate::plugin::{discover_plugins, PluginManifest};
15use crate::project_manifest::{
16 parse_source_scheme, version_satisfies, PluginRequirement, ProjectManifest, SourceScheme,
17};
18use crate::registry_client::{detect_platform, RegistryClient, RegistryIndex};
19
20#[derive(Debug)]
22pub enum PluginResolveResult {
23 AlreadyInstalled {
25 name: String,
26 installed_version: String,
27 },
28 Installed {
30 name: String,
31 version: String,
32 source: String,
33 },
34 BuiltFromSource { name: String, source_path: PathBuf },
36 Failed { name: String, reason: String },
38 Skipped { name: String, reason: String },
40}
41
42#[derive(Debug)]
44pub struct ResolveReport {
45 pub results: Vec<PluginResolveResult>,
47 pub missing_env_vars: Vec<(String, Vec<String>)>,
49}
50
51impl ResolveReport {
52 pub fn all_ok(&self) -> bool {
54 !self
55 .results
56 .iter()
57 .any(|r| matches!(r, PluginResolveResult::Failed { .. }))
58 }
59
60 pub fn success_count(&self) -> usize {
62 self.results
63 .iter()
64 .filter(|r| {
65 matches!(
66 r,
67 PluginResolveResult::AlreadyInstalled { .. }
68 | PluginResolveResult::Installed { .. }
69 | PluginResolveResult::BuiltFromSource { .. }
70 )
71 })
72 .count()
73 }
74
75 pub fn failure_count(&self) -> usize {
77 self.results
78 .iter()
79 .filter(|r| matches!(r, PluginResolveResult::Failed { .. }))
80 .count()
81 }
82}
83
84pub fn resolve_all(
94 manifest: &ProjectManifest,
95 project_root: &Path,
96 ci_mode: bool,
97) -> ResolveReport {
98 let platform = detect_platform();
99 let installed = discover_plugins(project_root);
100
101 let mut results = Vec::new();
102 let mut missing_env_vars = Vec::new();
103
104 let needs_registry = manifest
106 .plugins
107 .values()
108 .any(|r| r.source.starts_with("registry:"));
109 let registry_index = if needs_registry {
110 let client = RegistryClient::new();
111 match client.fetch_index() {
112 Ok(index) => Some(index),
113 Err(e) => {
114 tracing::warn!(error = %e, "Failed to fetch registry index");
115 None
116 }
117 }
118 } else {
119 None
120 };
121
122 for (name, requirement) in &manifest.plugins {
123 let missing: Vec<String> = requirement
125 .env_vars
126 .iter()
127 .filter(|var| std::env::var(var).is_err())
128 .cloned()
129 .collect();
130 if !missing.is_empty() {
131 missing_env_vars.push((name.clone(), missing));
132 }
133
134 let existing = installed.iter().find(|p| p.manifest.name == *name);
136
137 if let Some(existing) = existing {
138 if version_satisfies(&existing.manifest.version, &requirement.version) {
139 results.push(PluginResolveResult::AlreadyInstalled {
140 name: name.clone(),
141 installed_version: existing.manifest.version.clone(),
142 });
143 continue;
144 }
145 tracing::info!(
146 plugin = %name,
147 installed = %existing.manifest.version,
148 required = %requirement.version,
149 "Installed version does not satisfy requirement — upgrading"
150 );
151 }
152
153 let result = resolve_single(
155 name,
156 requirement,
157 project_root,
158 &platform,
159 registry_index.as_ref(),
160 );
161
162 if let PluginResolveResult::Failed { name, reason } = &result {
163 if !requirement.required && !ci_mode {
164 results.push(PluginResolveResult::Skipped {
165 name: name.clone(),
166 reason: reason.clone(),
167 });
168 continue;
169 }
170 }
171
172 results.push(result);
173 }
174
175 ResolveReport {
176 results,
177 missing_env_vars,
178 }
179}
180
181fn resolve_single(
183 name: &str,
184 requirement: &PluginRequirement,
185 project_root: &Path,
186 platform: &str,
187 registry_index: Option<&RegistryIndex>,
188) -> PluginResolveResult {
189 let scheme = match parse_source_scheme(name, &requirement.source) {
190 Ok(s) => s,
191 Err(e) => {
192 return PluginResolveResult::Failed {
193 name: name.to_string(),
194 reason: e.to_string(),
195 };
196 }
197 };
198
199 match scheme {
200 SourceScheme::Registry(registry_name) => resolve_from_registry(
201 name,
202 ®istry_name,
203 requirement,
204 project_root,
205 platform,
206 registry_index,
207 ),
208 SourceScheme::GitHub(repo) => {
209 resolve_from_github(name, &repo, requirement, project_root, platform)
210 }
211 SourceScheme::Path(source_path) => resolve_from_path(name, &source_path, project_root),
212 SourceScheme::Url(url) => resolve_from_url(name, &url, requirement, project_root),
213 }
214}
215
216fn resolve_from_registry(
218 name: &str,
219 registry_name: &str,
220 requirement: &PluginRequirement,
221 project_root: &Path,
222 platform: &str,
223 registry_index: Option<&RegistryIndex>,
224) -> PluginResolveResult {
225 let index = match registry_index {
226 Some(idx) => idx,
227 None => {
228 tracing::info!(
235 plugin = %name,
236 registry = %registry_name,
237 "Registry index unavailable — falling back to Trusted-Autonomy GitHub releases"
238 );
239 let github_repo = format!("Trusted-Autonomy/{}", registry_name);
240 let version =
244 crate::project_manifest::parse_min_version(&requirement.version).unwrap_or("0.1.0");
245 let url =
246 RegistryClient::github_release_url(&github_repo, registry_name, version, platform);
247 return match download_and_install(
248 name,
249 &url,
250 "",
251 &requirement.plugin_type,
252 project_root,
253 ) {
254 Ok(_) => PluginResolveResult::Installed {
255 name: name.to_string(),
256 version: version.to_string(),
257 source: format!("github:{}", github_repo),
258 },
259 Err(e) => PluginResolveResult::Failed {
260 name: name.to_string(),
261 reason: e,
262 },
263 };
264 }
265 };
266
267 let client = RegistryClient::new();
268 match client.resolve(index, registry_name, &requirement.version, platform) {
269 Ok(resolved) => {
270 match download_and_install(
271 name,
272 &resolved.download_url,
273 &resolved.sha256,
274 &requirement.plugin_type,
275 project_root,
276 ) {
277 Ok(_) => PluginResolveResult::Installed {
278 name: name.to_string(),
279 version: resolved.version,
280 source: format!("registry:{}", registry_name),
281 },
282 Err(e) => PluginResolveResult::Failed {
283 name: name.to_string(),
284 reason: e,
285 },
286 }
287 }
288 Err(e) => PluginResolveResult::Failed {
289 name: name.to_string(),
290 reason: e.to_string(),
291 },
292 }
293}
294
295fn resolve_from_github(
297 name: &str,
298 repo: &str,
299 requirement: &PluginRequirement,
300 project_root: &Path,
301 platform: &str,
302) -> PluginResolveResult {
303 let version =
305 crate::project_manifest::parse_min_version(&requirement.version).unwrap_or("0.1.0");
306 let url = RegistryClient::github_release_url(repo, name, version, platform);
307
308 match download_and_install(name, &url, "", &requirement.plugin_type, project_root) {
310 Ok(_) => PluginResolveResult::Installed {
311 name: name.to_string(),
312 version: version.to_string(),
313 source: format!("github:{}", repo),
314 },
315 Err(e) => PluginResolveResult::Failed {
316 name: name.to_string(),
317 reason: e,
318 },
319 }
320}
321
322fn resolve_from_path(name: &str, source_path: &Path, project_root: &Path) -> PluginResolveResult {
324 let abs_path = if source_path.is_relative() {
326 project_root.join(source_path)
327 } else {
328 source_path.to_path_buf()
329 };
330
331 if !abs_path.exists() {
332 return PluginResolveResult::Failed {
333 name: name.to_string(),
334 reason: format!(
335 "Source path '{}' does not exist. Check the 'source' field in project.toml.",
336 abs_path.display()
337 ),
338 };
339 }
340
341 match build_from_source(name, &abs_path, project_root) {
343 Ok(_) => PluginResolveResult::BuiltFromSource {
344 name: name.to_string(),
345 source_path: abs_path,
346 },
347 Err(e) => PluginResolveResult::Failed {
348 name: name.to_string(),
349 reason: e,
350 },
351 }
352}
353
354fn resolve_from_url(
356 name: &str,
357 url: &str,
358 requirement: &PluginRequirement,
359 project_root: &Path,
360) -> PluginResolveResult {
361 match download_and_install(name, url, "", &requirement.plugin_type, project_root) {
362 Ok(_) => PluginResolveResult::Installed {
363 name: name.to_string(),
364 version: "unknown".to_string(),
365 source: format!("url:{}", url),
366 },
367 Err(e) => PluginResolveResult::Failed {
368 name: name.to_string(),
369 reason: e,
370 },
371 }
372}
373
374fn download_and_install(
376 name: &str,
377 url: &str,
378 expected_sha256: &str,
379 plugin_type: &str,
380 project_root: &Path,
381) -> Result<PathBuf, String> {
382 tracing::info!(plugin = %name, url = %url, "Downloading plugin");
383
384 let client = reqwest::blocking::Client::builder()
385 .timeout(std::time::Duration::from_secs(120))
386 .build()
387 .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
388
389 let resp = client
390 .get(url)
391 .send()
392 .map_err(|e| format!("Download failed from {}: {}", url, e))?;
393
394 if !resp.status().is_success() {
395 return Err(format!(
396 "Download failed: HTTP {} from {}. Check the URL and try again.",
397 resp.status(),
398 url
399 ));
400 }
401
402 let bytes = resp
403 .bytes()
404 .map_err(|e| format!("Failed to read response body: {}", e))?;
405
406 if !expected_sha256.is_empty() {
408 let mut hasher = Sha256::new();
409 hasher.update(&bytes);
410 let actual_hash = format!("{:x}", hasher.finalize());
411 if actual_hash != expected_sha256 {
412 return Err(format!(
413 "SHA-256 mismatch for '{}': expected {}, got {}. \
414 The download may be corrupted or tampered with.",
415 name, expected_sha256, actual_hash
416 ));
417 }
418 }
419
420 let install_dir = project_root
422 .join(".ta")
423 .join("plugins")
424 .join(plugin_type)
425 .join(name);
426 std::fs::create_dir_all(&install_dir).map_err(|e| {
427 format!(
428 "Failed to create plugin directory {}: {}",
429 install_dir.display(),
430 e
431 )
432 })?;
433
434 extract_tarball(&bytes, &install_dir)
436 .map_err(|e| format!("Failed to extract plugin tarball: {}", e))?;
437
438 tracing::info!(
439 plugin = %name,
440 path = %install_dir.display(),
441 "Plugin installed successfully"
442 );
443
444 Ok(install_dir)
445}
446
447fn extract_tarball(data: &[u8], target_dir: &Path) -> Result<(), String> {
449 let cursor = std::io::Cursor::new(data);
451 let gz = flate2_decode(cursor);
452
453 match gz {
454 Ok(decompressed) => tar_extract(std::io::Cursor::new(decompressed), target_dir),
455 Err(_) => {
456 tar_extract(std::io::Cursor::new(data), target_dir)
458 }
459 }
460}
461
462fn flate2_decode<R: std::io::Read>(_reader: R) -> Result<Vec<u8>, String> {
464 Err("gzip decompression requires system tar".to_string())
468}
469
470fn tar_extract<R: std::io::Read>(reader: R, target_dir: &Path) -> Result<(), String> {
472 let temp_dir = target_dir.parent().unwrap_or(target_dir);
474 let temp_file = temp_dir.join(format!(".download-{}.tar.gz", std::process::id()));
475
476 let mut reader = reader;
479 let mut buf = Vec::new();
480 reader
481 .read_to_end(&mut buf)
482 .map_err(|e| format!("Failed to buffer download: {}", e))?;
483
484 std::fs::write(&temp_file, &buf).map_err(|e| format!("Failed to write temp file: {}", e))?;
485
486 let output = std::process::Command::new("tar")
487 .args([
488 "xzf",
489 &temp_file.to_string_lossy(),
490 "-C",
491 &target_dir.to_string_lossy(),
492 ])
493 .output()
494 .map_err(|e| format!("Failed to run tar: {}", e))?;
495
496 let _ = std::fs::remove_file(&temp_file);
498
499 if !output.status.success() {
500 std::fs::write(&temp_file, &buf)
502 .map_err(|e| format!("Failed to write temp file: {}", e))?;
503 let output2 = std::process::Command::new("tar")
504 .args([
505 "xf",
506 &temp_file.to_string_lossy(),
507 "-C",
508 &target_dir.to_string_lossy(),
509 ])
510 .output()
511 .map_err(|e| format!("Failed to run tar: {}", e))?;
512 let _ = std::fs::remove_file(&temp_file);
513 if !output2.status.success() {
514 return Err(format!(
515 "tar extraction failed: {}",
516 String::from_utf8_lossy(&output2.stderr)
517 ));
518 }
519 }
520
521 Ok(())
522}
523
524fn build_from_source(name: &str, source_dir: &Path, project_root: &Path) -> Result<(), String> {
531 tracing::info!(
532 plugin = %name,
533 source = %source_dir.display(),
534 "Building plugin from source"
535 );
536
537 let manifest_path = source_dir.join("channel.toml");
539 let custom_build = if manifest_path.exists() {
540 PluginManifest::load(&manifest_path)
541 .ok()
542 .and_then(|m| m.build_command.clone())
543 } else {
544 None
545 };
546
547 let (cmd, args) = if let Some(ref build_cmd) = custom_build {
548 let parts: Vec<&str> = build_cmd.split_whitespace().collect();
550 if parts.is_empty() {
551 return Err("Empty build_command in channel.toml".to_string());
552 }
553 (
554 parts[0].to_string(),
555 parts[1..].iter().map(|s| s.to_string()).collect::<Vec<_>>(),
556 )
557 } else if source_dir.join("Cargo.toml").exists() {
558 (
560 "cargo".to_string(),
561 vec!["build".to_string(), "--release".to_string()],
562 )
563 } else if source_dir.join("go.mod").exists() {
564 (
566 "go".to_string(),
567 vec![
568 "build".to_string(),
569 "-o".to_string(),
570 format!("ta-channel-{}", name),
571 ],
572 )
573 } else if source_dir.join("Makefile").exists() {
574 ("make".to_string(), vec![])
575 } else {
576 return Err(format!(
577 "Cannot determine how to build plugin '{}' at {}. \
578 Add a Cargo.toml, go.mod, Makefile, or set build_command in channel.toml.",
579 name,
580 source_dir.display()
581 ));
582 };
583
584 let output = std::process::Command::new(&cmd)
585 .args(&args)
586 .current_dir(source_dir)
587 .output()
588 .map_err(|e| {
589 format!(
590 "Failed to run build command '{} {}': {}. \
591 Make sure the toolchain is installed and on PATH.",
592 cmd,
593 args.join(" "),
594 e
595 )
596 })?;
597
598 if !output.status.success() {
599 let stderr = String::from_utf8_lossy(&output.stderr);
600 let last_lines: Vec<&str> = stderr.lines().rev().take(20).collect();
601 return Err(format!(
602 "Build failed for plugin '{}'. Command: {} {}\nLast 20 lines of output:\n{}",
603 name,
604 cmd,
605 args.join(" "),
606 last_lines.into_iter().rev().collect::<Vec<_>>().join("\n")
607 ));
608 }
609
610 let install_dir = project_root
612 .join(".ta")
613 .join("plugins")
614 .join("channels")
615 .join(name);
616 std::fs::create_dir_all(&install_dir)
617 .map_err(|e| format!("Failed to create install dir: {}", e))?;
618
619 crate::plugin::copy_dir_contents_public(source_dir, &install_dir)
620 .map_err(|e| format!("Failed to copy plugin files: {}", e))?;
621
622 let release_binary = source_dir
624 .join("target")
625 .join("release")
626 .join(format!("ta-channel-{}", name));
627 if release_binary.exists() {
628 let dest = install_dir.join(format!("ta-channel-{}", name));
629 std::fs::copy(&release_binary, &dest)
630 .map_err(|e| format!("Failed to copy release binary: {}", e))?;
631 }
632
633 tracing::info!(
634 plugin = %name,
635 install_dir = %install_dir.display(),
636 "Plugin built and installed from source"
637 );
638
639 Ok(())
640}
641
642pub fn check_requirements(
647 manifest: &ProjectManifest,
648 project_root: &Path,
649) -> Vec<(String, String)> {
650 let installed = discover_plugins(project_root);
651 let mut issues = Vec::new();
652
653 for (name, requirement) in &manifest.plugins {
654 if !requirement.required {
655 continue;
656 }
657
658 let existing = installed.iter().find(|p| p.manifest.name == *name);
659
660 match existing {
661 None => {
662 issues.push((
663 name.clone(),
664 format!(
665 "Required plugin '{}' is not installed. Run `ta setup` to install it.",
666 name
667 ),
668 ));
669 }
670 Some(p) => {
671 if !version_satisfies(&p.manifest.version, &requirement.version) {
672 issues.push((
673 name.clone(),
674 format!(
675 "Plugin '{}' version {} does not satisfy requirement {}. \
676 Run `ta setup` to upgrade.",
677 name, p.manifest.version, requirement.version
678 ),
679 ));
680 }
681 }
682 }
683 }
684
685 issues
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use crate::project_manifest::ProjectManifest;
692
693 #[test]
694 fn check_requirements_all_installed() {
695 let dir = tempfile::tempdir().unwrap();
696 let plugins_dir = dir.path().join(".ta").join("plugins").join("channels");
697
698 let plugin_dir = plugins_dir.join("test-plugin");
700 std::fs::create_dir_all(&plugin_dir).unwrap();
701 std::fs::write(
702 plugin_dir.join("channel.toml"),
703 r#"
704name = "test-plugin"
705version = "0.2.0"
706command = "test"
707protocol = "json-stdio"
708"#,
709 )
710 .unwrap();
711
712 let toml_str = r#"
713[project]
714name = "test"
715
716[plugins.test-plugin]
717type = "channel"
718version = ">=0.1.0"
719source = "registry:test-plugin"
720"#;
721 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
722 let issues = check_requirements(&manifest, dir.path());
723 assert!(issues.is_empty(), "expected no issues: {:?}", issues);
724 }
725
726 #[test]
727 fn check_requirements_missing_plugin() {
728 let dir = tempfile::tempdir().unwrap();
729
730 let toml_str = r#"
731[project]
732name = "test"
733
734[plugins.missing]
735type = "channel"
736version = ">=0.1.0"
737source = "registry:missing"
738"#;
739 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
740 let issues = check_requirements(&manifest, dir.path());
741 assert_eq!(issues.len(), 1);
742 assert!(issues[0].1.contains("not installed"));
743 }
744
745 #[test]
746 fn check_requirements_version_too_low() {
747 let dir = tempfile::tempdir().unwrap();
748 let plugins_dir = dir.path().join(".ta").join("plugins").join("channels");
749
750 let plugin_dir = plugins_dir.join("old-plugin");
751 std::fs::create_dir_all(&plugin_dir).unwrap();
752 std::fs::write(
753 plugin_dir.join("channel.toml"),
754 r#"
755name = "old-plugin"
756version = "0.0.5"
757command = "test"
758protocol = "json-stdio"
759"#,
760 )
761 .unwrap();
762
763 let toml_str = r#"
764[project]
765name = "test"
766
767[plugins.old-plugin]
768type = "channel"
769version = ">=0.1.0"
770source = "registry:old-plugin"
771"#;
772 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
773 let issues = check_requirements(&manifest, dir.path());
774 assert_eq!(issues.len(), 1);
775 assert!(issues[0].1.contains("does not satisfy"));
776 }
777
778 #[test]
779 fn check_requirements_optional_not_reported() {
780 let dir = tempfile::tempdir().unwrap();
781
782 let toml_str = r#"
783[project]
784name = "test"
785
786[plugins.optional-thing]
787type = "channel"
788version = ">=0.1.0"
789source = "registry:optional-thing"
790required = false
791"#;
792 let manifest: ProjectManifest = toml::from_str(toml_str).unwrap();
793 let issues = check_requirements(&manifest, dir.path());
794 assert!(issues.is_empty());
795 }
796
797 #[test]
798 fn resolve_report_methods() {
799 let report = ResolveReport {
800 results: vec![
801 PluginResolveResult::AlreadyInstalled {
802 name: "a".into(),
803 installed_version: "0.1.0".into(),
804 },
805 PluginResolveResult::Installed {
806 name: "b".into(),
807 version: "0.2.0".into(),
808 source: "registry:b".into(),
809 },
810 PluginResolveResult::Failed {
811 name: "c".into(),
812 reason: "not found".into(),
813 },
814 PluginResolveResult::Skipped {
815 name: "d".into(),
816 reason: "optional".into(),
817 },
818 ],
819 missing_env_vars: vec![("b".into(), vec!["TOKEN".into()])],
820 };
821
822 assert!(!report.all_ok());
823 assert_eq!(report.success_count(), 2);
824 assert_eq!(report.failure_count(), 1);
825 }
826
827 #[test]
828 fn resolve_report_all_ok() {
829 let report = ResolveReport {
830 results: vec![PluginResolveResult::AlreadyInstalled {
831 name: "a".into(),
832 installed_version: "0.1.0".into(),
833 }],
834 missing_env_vars: vec![],
835 };
836
837 assert!(report.all_ok());
838 assert_eq!(report.success_count(), 1);
839 assert_eq!(report.failure_count(), 0);
840 }
841
842 #[test]
843 fn build_from_source_no_toolchain() {
844 let dir = tempfile::tempdir().unwrap();
845 let source = tempfile::tempdir().unwrap();
846 let result = build_from_source("test", source.path(), dir.path());
848 assert!(result.is_err());
849 assert!(result.unwrap_err().contains("Cannot determine"));
850 }
851
852 #[test]
853 fn sha256_verification() {
854 use sha2::{Digest, Sha256};
855 let data = b"hello world";
856 let mut hasher = Sha256::new();
857 hasher.update(data);
858 let hash = format!("{:x}", hasher.finalize());
859 assert_eq!(
860 hash,
861 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
862 );
863 }
864}