use crate::manifest_lookup::resolve_plugin_ref;
use crate::plugins::package_spec::{is_git_url, is_local_path};
use r2x_config::Config;
use r2x_logger as logger;
use r2x_manifest::types::{Manifest, Package, PackageSource};
use r2x_python::utils::resolve_site_package_path;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum VerificationResult {
Valid,
Missing(Vec<String>),
}
#[derive(Debug)]
pub enum VerificationError {
VenvNotFound(PathBuf),
VerificationFailed(String),
ReinstallFailed(String),
}
impl std::fmt::Display for VerificationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VerificationError::VenvNotFound(path) => {
write!(f, "Virtual environment not found at: {}", path.display())
}
VerificationError::VerificationFailed(msg) => {
write!(f, "Package verification failed: {}", msg)
}
VerificationError::ReinstallFailed(msg) => {
write!(f, "Package reinstallation failed: {}", msg)
}
}
}
}
impl std::error::Error for VerificationError {}
fn verify_plugin_packages(
manifest: &Manifest,
plugin_key: &str,
) -> Result<VerificationResult, VerificationError> {
logger::debug(&format!("Verifying packages for plugin: {}", plugin_key));
let resolved = resolve_plugin_ref(manifest, plugin_key)
.map_err(|e| VerificationError::VerificationFailed(e.to_string()))?;
let package_name = resolved.package.name.to_string();
let config = Config::load().map_err(|e| {
VerificationError::VerificationFailed(format!("Failed to load config: {}", e))
})?;
let venv_path = PathBuf::from(config.get_venv_path());
if !venv_path.exists() {
return Err(VerificationError::VenvNotFound(venv_path));
}
let missing_packages = check_packages_installed(&venv_path, &[&package_name])?;
if missing_packages.is_empty() {
logger::debug(&format!("Package '{}' verified successfully", package_name));
Ok(VerificationResult::Valid)
} else {
logger::debug(&format!("Missing packages: {:?}", missing_packages));
Ok(VerificationResult::Missing(missing_packages))
}
}
fn check_packages_installed(
venv_path: &Path,
packages: &[&str],
) -> Result<Vec<String>, VerificationError> {
let site_packages = get_site_packages_dir(venv_path)?;
let mut missing = Vec::new();
for package in packages {
let package_dir_name = package.replace('-', "_");
let package_dir = site_packages.join(&package_dir_name);
let dist_info_pattern = format!("{}-*.dist-info", package_dir_name);
let package_exists =
package_dir.exists() || dist_info_exists(&site_packages, &dist_info_pattern);
if package_exists {
logger::debug(&format!("Package '{}' found in site-packages", package));
} else {
logger::debug(&format!("Package '{}' not found in site-packages", package));
missing.push((*package).to_string());
}
}
Ok(missing)
}
fn get_site_packages_dir(venv_path: &Path) -> Result<PathBuf, VerificationError> {
logger::debug(&format!(
"Getting site-packages directory for venv: {}",
venv_path.display()
));
resolve_site_package_path(venv_path).map_err(|e| match e {
r2x_python::errors::BridgeError::VenvNotFound(path) => {
VerificationError::VenvNotFound(path)
}
_ => VerificationError::VerificationFailed(format!("{}", e)),
})
}
fn dist_info_exists(site_packages: &Path, pattern: &str) -> bool {
let pattern_prefix = pattern.split('-').next().unwrap_or("");
if let Ok(entries) = std::fs::read_dir(site_packages) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(pattern_prefix) && name_str.ends_with(".dist-info") {
return true;
}
}
}
false
}
fn ensure_manifest_package(package: &Package, config: &Config) -> Result<(), VerificationError> {
let (package_spec, editable) = manifest_install_spec(package)?;
let python_exe = config.get_venv_python_path();
let install_args = build_pip_install_args(&python_exe, &[package_spec], editable);
run_pip_install(config, &install_args, 1)
}
fn manifest_install_spec(package: &Package) -> Result<(&str, bool), VerificationError> {
let source_uri = package
.source_uri
.as_deref()
.filter(|source_uri| !source_uri.trim().is_empty());
let source_is_explicit = source_uri.is_some_and(|source_uri| {
is_git_url(source_uri) || is_local_path(source_uri) || source_uri.starts_with("file:")
});
let source_is_required = package.editable_install
|| source_is_explicit
|| matches!(
package.source_kind,
PackageSource::Github | PackageSource::Git | PackageSource::Local
);
if source_is_required {
let source_uri = source_uri.ok_or_else(|| {
VerificationError::ReinstallFailed(format!(
"Package '{}' requires its original source URI for reinstallation",
package.name
))
})?;
return Ok((source_uri, package.editable_install));
}
Ok((package.name.as_ref(), false))
}
fn build_pip_install_args(python_exe: &str, packages: &[&str], editable: bool) -> Vec<String> {
let mut install_args = vec![
"pip".to_string(),
"install".to_string(),
"--python".to_string(),
python_exe.to_string(),
"--prerelease=allow".to_string(),
];
if editable {
install_args.push("-e".to_string());
}
install_args.extend(packages.iter().map(|package| (*package).to_string()));
install_args
}
fn run_pip_install(
config: &Config,
install_args: &[String],
package_count: usize,
) -> Result<(), VerificationError> {
let uv_path = config
.uv_path
.as_ref()
.ok_or_else(|| VerificationError::ReinstallFailed("uv not configured".to_string()))?;
let package_target = install_args
.last()
.map_or("requested packages", String::as_str);
crate::uv::run(
uv_path,
"Installing missing package",
package_target,
install_args.to_vec(),
)
.map_err(|error| VerificationError::ReinstallFailed(error.to_string()))?;
logger::success(&format!(
"Successfully installed {package_count} package(s)"
));
Ok(())
}
pub(crate) fn verify_and_ensure_plugin(
manifest: &Manifest,
plugin_key: &str,
) -> Result<(), VerificationError> {
logger::debug(&format!("Verifying and ensuring plugin: {}", plugin_key));
match verify_plugin_packages(manifest, plugin_key)? {
VerificationResult::Valid => {
logger::debug("All packages verified successfully");
Ok(())
}
VerificationResult::Missing(packages) => {
logger::info(&format!(
"Missing {} package(s), reinstalling...",
packages.len()
));
let config = Config::load().map_err(|e| {
VerificationError::ReinstallFailed(format!("Failed to load config: {}", e))
})?;
let resolved = resolve_plugin_ref(manifest, plugin_key)
.map_err(|e| VerificationError::ReinstallFailed(e.to_string()))?;
ensure_manifest_package(resolved.package, &config)?;
logger::success("Packages verified and installed");
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use crate::package_verification::*;
use r2x_manifest::types::{Package, PackageSource};
use std::sync::Arc;
fn package_with_source(
source_kind: PackageSource,
source_uri: Option<&str>,
editable_install: bool,
) -> Package {
Package {
name: Arc::from("r2x-reeds"),
source_kind,
source_uri: source_uri.map(Arc::from),
editable_install,
..Default::default()
}
}
#[test]
fn test_verification_result_valid() {
let result = VerificationResult::Valid;
assert_eq!(result, VerificationResult::Valid);
}
#[test]
fn test_verification_result_missing() {
let packages = vec!["r2x-reeds".to_string(), "r2x-core".to_string()];
let result = VerificationResult::Missing(packages.clone());
match result {
VerificationResult::Missing(p) => assert_eq!(p, packages),
VerificationResult::Valid => unreachable!("Expected Missing variant"),
}
}
#[test]
fn test_package_name_conversion() {
let package = "r2x-reeds";
let converted = package.replace('-', "_");
assert_eq!(converted, "r2x_reeds");
}
#[test]
fn test_verification_error_display() {
let err = VerificationError::VerificationFailed("test error".to_string());
assert_eq!(err.to_string(), "Package verification failed: test error");
}
#[test]
fn test_dist_info_pattern() {
let pattern = "r2x_reeds-*.dist-info";
let pattern_prefix = pattern.split('-').next().unwrap_or("");
assert_eq!(pattern_prefix, "r2x_reeds");
let example_dist_info = "r2x_reeds-1.2.3.dist-info";
assert!(example_dist_info.starts_with(pattern_prefix));
assert!(example_dist_info.ends_with(".dist-info"));
}
#[test]
fn test_verification_workflow() {
let valid_result = VerificationResult::Valid;
let missing_result = VerificationResult::Missing(vec!["r2x-reeds".to_string()]);
assert!(matches!(missing_result, VerificationResult::Missing(_)));
assert!(matches!(valid_result, VerificationResult::Valid));
}
#[test]
fn manifest_reinstall_uses_package_name_for_pypi() {
let package = package_with_source(PackageSource::Pypi, None, false);
let Ok((package_spec, editable)) = manifest_install_spec(&package) else {
unreachable!("PyPI install spec should resolve");
};
let args = build_pip_install_args("/tmp/python", &[package_spec], editable);
assert_eq!(args.last(), Some(&"r2x-reeds".to_string()));
assert!(!args.iter().any(|arg| arg == "-e"));
}
#[test]
fn manifest_reinstall_uses_editable_local_source() {
let package = package_with_source(PackageSource::Local, Some("/tmp/r2x-reeds"), true);
let Ok((package_spec, editable)) = manifest_install_spec(&package) else {
unreachable!("local install spec should resolve");
};
let args = build_pip_install_args("/tmp/python", &[package_spec], editable);
assert!(args.iter().any(|arg| arg == "-e"));
assert_eq!(args.last(), Some(&"/tmp/r2x-reeds".to_string()));
}
#[test]
fn manifest_reinstall_uses_git_uri_despite_legacy_pypi_kind() {
let package = package_with_source(
PackageSource::Pypi,
Some("git+https://github.com/NREL/r2x-reeds.git@main"),
false,
);
let Ok((package_spec, editable)) = manifest_install_spec(&package) else {
unreachable!("git install spec should resolve");
};
assert_eq!(
package_spec,
"git+https://github.com/NREL/r2x-reeds.git@main"
);
assert!(!editable);
}
#[test]
fn manifest_reinstall_rejects_missing_required_source_uri() {
let package = package_with_source(PackageSource::Local, None, true);
let error = match manifest_install_spec(&package) {
Err(error) => error,
Ok(_) => unreachable!("editable local package without source URI should fail"),
};
assert!(error
.to_string()
.contains("requires its original source URI"));
}
}