Skip to main content

ta_changeset/
plugin_resolver.rs

1// plugin_resolver.rs — Resolve, download, verify, and install plugins from a project manifest.
2//
3// This is the core engine behind `ta setup`. Given a ProjectManifest, it:
4// 1. Checks which plugins are already installed and their versions
5// 2. Downloads missing/outdated plugins from registry, GitHub, or URL
6// 3. Verifies SHA-256 hashes
7// 4. Extracts tarballs to `.ta/plugins/<type>/<name>/`
8// 5. Falls back to source builds for `path:` sources
9
10use 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/// Result of resolving a single plugin.
21#[derive(Debug)]
22pub enum PluginResolveResult {
23    /// Plugin already installed and version satisfies the constraint.
24    AlreadyInstalled {
25        name: String,
26        installed_version: String,
27    },
28    /// Plugin was freshly installed.
29    Installed {
30        name: String,
31        version: String,
32        source: String,
33    },
34    /// Plugin was built from source.
35    BuiltFromSource { name: String, source_path: PathBuf },
36    /// Plugin resolution failed.
37    Failed { name: String, reason: String },
38    /// Plugin was skipped (optional and not available).
39    Skipped { name: String, reason: String },
40}
41
42/// Result of a full `ta setup` resolution.
43#[derive(Debug)]
44pub struct ResolveReport {
45    /// Results for each plugin.
46    pub results: Vec<PluginResolveResult>,
47    /// Environment variables that are missing.
48    pub missing_env_vars: Vec<(String, Vec<String>)>,
49}
50
51impl ResolveReport {
52    /// Check if all required plugins resolved successfully.
53    pub fn all_ok(&self) -> bool {
54        !self
55            .results
56            .iter()
57            .any(|r| matches!(r, PluginResolveResult::Failed { .. }))
58    }
59
60    /// Count of successfully installed or already-present plugins.
61    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    /// Count of failed plugins.
76    pub fn failure_count(&self) -> usize {
77        self.results
78            .iter()
79            .filter(|r| matches!(r, PluginResolveResult::Failed { .. }))
80            .count()
81    }
82}
83
84/// Resolve all plugins declared in a project manifest.
85///
86/// For each plugin:
87/// 1. Check if already installed with a satisfying version
88/// 2. Based on source scheme, download or build
89/// 3. Verify integrity (sha256 for downloads)
90/// 4. Install to `.ta/plugins/<type>/<name>/`
91///
92/// `ci_mode`: If true, treat optional plugin failures as hard errors.
93pub 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    // Try to fetch registry index (only if any plugin uses registry: source).
105    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        // Check environment variables.
124        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        // Check if already installed with satisfying version.
135        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        // Resolve based on source scheme.
154        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
181/// Resolve a single plugin from its requirement.
182fn 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            &registry_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
216/// Resolve from the TA plugin registry.
217fn 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            // Registry index unavailable (network error or registry not yet live).
229            // Fall back to the canonical GitHub releases URL for official TA plugins:
230            //   https://github.com/Trusted-Autonomy/{registry_name}/releases/download/...
231            // This lets `source = "registry:ta-channel-discord"` work before
232            // registry.trustedautonomy.dev exists, as long as the GitHub release
233            // binaries are published.
234            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            // Use `registry_name` for the binary filename (e.g. "ta-channel-discord"),
241            // not the plugin key `name` (e.g. "discord"), so the tarball URL matches
242            // the actual release artifact name.
243            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
295/// Resolve from a GitHub release.
296fn resolve_from_github(
297    name: &str,
298    repo: &str,
299    requirement: &PluginRequirement,
300    project_root: &Path,
301    platform: &str,
302) -> PluginResolveResult {
303    // Extract minimum version from constraint for the download URL.
304    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    // GitHub releases don't have pre-known sha256, so we skip verification.
309    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
322/// Resolve from a local path (source build).
323fn resolve_from_path(name: &str, source_path: &Path, project_root: &Path) -> PluginResolveResult {
324    // Resolve relative paths against project root.
325    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    // Try to build from source.
342    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
354/// Resolve from a direct URL.
355fn 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
374/// Download a tarball, verify its hash, and extract to the plugin directory.
375fn 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    // Verify SHA-256 if provided.
407    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    // Determine install directory.
421    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.
435    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
447/// Extract a gzipped tarball to a directory.
448fn extract_tarball(data: &[u8], target_dir: &Path) -> Result<(), String> {
449    // Try gzip first.
450    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            // Maybe it's not gzipped — try raw tar.
457            tar_extract(std::io::Cursor::new(data), target_dir)
458        }
459    }
460}
461
462/// Decompress gzip data.
463fn flate2_decode<R: std::io::Read>(_reader: R) -> Result<Vec<u8>, String> {
464    // Simple gzip decompression using the flate2 crate would be ideal,
465    // but to avoid adding a dependency, we shell out to gunzip.
466    // For the MVP, we write to a temp file and use the system tar command.
467    Err("gzip decompression requires system tar".to_string())
468}
469
470/// Extract a tar archive using the system `tar` command.
471fn tar_extract<R: std::io::Read>(reader: R, target_dir: &Path) -> Result<(), String> {
472    // Write data to temp file and extract with system tar.
473    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    // The data coming in is the original bytes (potentially gzipped).
477    // We'll write it and let `tar` auto-detect compression.
478    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    // Clean up temp file.
497    let _ = std::fs::remove_file(&temp_file);
498
499    if !output.status.success() {
500        // Try without -z (not gzipped).
501        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
524/// Build a plugin from source.
525///
526/// Detects the toolchain and runs the appropriate build command:
527/// - Rust: `cargo build --release`
528/// - Go: `go build`
529/// - Other: uses `build_command` from channel.toml or `make`
530fn 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    // Check for a channel.toml with a custom build command.
538    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        // Use custom build command.
549        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        // Rust plugin.
559        (
560            "cargo".to_string(),
561            vec!["build".to_string(), "--release".to_string()],
562        )
563    } else if source_dir.join("go.mod").exists() {
564        // Go plugin.
565        (
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    // Install: copy the plugin directory contents to the project plugin dir.
611    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    // For Rust plugins, also copy the release binary.
623    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
642/// Check all required plugins from a manifest against installed plugins.
643///
644/// Returns a list of (name, issue) for any plugin that is missing or
645/// below the required version. Used by daemon startup to enforce requirements.
646pub 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        // Install a plugin.
699        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        // No Cargo.toml, go.mod, or Makefile.
847        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}