use crate::commands::plugins::context::PluginContext;
use crate::plugins::{
discovery::{discover_and_register_entry_points_with_deps, DiscoveryOptions},
error::PluginError,
install::get_package_info,
package_spec::{build_package_spec, extract_package_name},
};
use crate::uv;
use colored::Colorize;
use r2x_logger as logger;
use r2x_manifest::package_discovery::PackageDiscoverer;
use std::fs;
use std::path::Path;
pub struct GitOptions {
pub host: Option<String>,
pub branch: Option<String>,
pub tag: Option<String>,
pub commit: Option<String>,
}
pub fn install_plugin(
package: &str,
editable: bool,
no_cache: bool,
git_opts: GitOptions,
ctx: &mut PluginContext,
) -> Result<(), PluginError> {
logger::debug("Loading configuration for plugin installation");
let total_start = std::time::Instant::now();
let package_spec = build_package_spec(
package,
git_opts.host.clone(),
git_opts.branch.clone(),
git_opts.tag.clone(),
git_opts.commit.clone(),
)?;
if is_workspace_package(&package_spec)? {
logger::status("Detected workspace repository, installing all members...");
run_pip_install(
&ctx.uv_path,
&ctx.python_path,
&package_spec,
editable,
no_cache,
)?;
ctx.refresh_locator()?;
logger::status("Discovering plugins from installed packages...");
return discover_all_installed_packages(ctx, true, total_start);
}
let package_name_for_query = extract_package_name(package)?;
let can_reuse_installed_package = !editable
&& !crate::plugins::package_spec::is_git_url(&package_spec)
&& !crate::plugins::package_spec::is_local_path(&package_spec);
let check_start = std::time::Instant::now();
let installed_info = if no_cache || !can_reuse_installed_package {
None
} else {
get_package_info(&ctx.uv_path, &ctx.python_path, &package_name_for_query).ok()
};
let is_already_installed = installed_info.as_ref().is_some_and(|(version, _deps)| {
ctx.manifest
.get_package(&package_name_for_query)
.is_some_and(|pkg| {
let version_matches = version
.as_deref()
.is_some_and(|installed_version| pkg.version.as_ref() == installed_version);
!pkg.plugins.is_empty() && version_matches
})
});
if is_already_installed {
logger::debug(&format!(
"Package '{}' already installed and registered (check took {:?}); refreshing plugin metadata",
package_name_for_query,
check_start.elapsed()
));
} else {
let start = std::time::Instant::now();
match run_pip_install(
&ctx.uv_path,
&ctx.python_path,
&package_spec,
editable,
no_cache,
) {
Ok(()) => {
logger::debug(&format!("pip install took: {:?}", start.elapsed()));
}
Err(e) => return Err(e),
}
ctx.refresh_locator()?;
}
let start = std::time::Instant::now();
let (package_version, dependencies) = if is_already_installed {
installed_info.unwrap_or((None, Vec::new()))
} else {
match get_package_info(&ctx.uv_path, &ctx.python_path, &package_name_for_query) {
Ok((version, deps)) => (version, deps),
Err(e) => {
logger::debug(&format!("Failed to get package info: {}", e));
(None, Vec::new())
}
}
};
logger::debug(&format!("get_package_info took: {:?}", start.elapsed()));
let source_path = if editable && Path::new(package).exists() {
fs::canonicalize(package)
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
} else {
None
};
let source_uri = if crate::plugins::package_spec::is_git_url(&package_spec) {
Some(package_spec.clone())
} else {
source_path.clone()
};
let start = std::time::Instant::now();
let entry_count = discover_and_register_entry_points_with_deps(
&ctx.locator,
Some(&ctx.venv_path),
&mut ctx.manifest,
DiscoveryOptions {
package: package.to_string(),
package_name_full: package_name_for_query.clone(),
dependencies,
package_version: package_version.clone(),
no_cache: true,
editable,
source_path,
source_uri,
},
)?;
logger::debug(&format!(
"discover_and_register_entry_points took: {:?}",
start.elapsed()
));
print_install_summary(
&package_name_for_query,
package_version.as_deref().unwrap_or(""),
entry_count,
total_start.elapsed(),
);
Ok(())
}
pub fn show_install_help() -> Result<(), PluginError> {
println!();
println!("{}", "Install a plugin package".bold());
println!();
println!("{}", "Usage:".bold());
println!(" r2x install <PLUGIN> [OPTIONS]");
println!();
println!("{}", "Arguments:".bold());
println!(" <PLUGIN> Package name, local path, or git URL to install");
println!();
println!("{}", "Options:".bold());
println!(" -e, --editable Install in editable mode (for development)");
println!(" --no-cache Skip metadata cache and force rebuild");
println!(" --host <HOST> Git host (default: github.com)");
println!(" --branch <BRANCH> Install from a git branch");
println!(" --tag <TAG> Install from a git tag");
println!(" --commit <COMMIT> Install from a git commit hash");
println!();
println!("{}", "Examples:".bold());
println!(" Install from PyPI:\n r2x install r2x-reeds");
println!("\n Install from local path:\n r2x install ./packages/r2x-reeds");
println!("\n Install from GitHub (gh:owner/repo):\n r2x install gh:NREL/r2x-reeds");
println!(
"\n Install from specific branch:\n r2x install gh:NREL/r2x-reeds --branch develop"
);
println!("\n Install from git tag:\n r2x install gh:NREL/r2x-reeds --tag v0.1.0");
println!(
"\n Install in editable mode for development:\n r2x install -e ./packages/r2x-reeds"
);
println!("\n Install workspace (all packages in monorepo):\n r2x install https://github.com/NREL/R2X --branch v2.0.0");
println!("\n Install local workspace:\n r2x install ./R2X");
println!();
println!("{}", "Workspace Installation:".bold());
println!(" When installing from a repository with [tool.uv.workspace] in its");
println!(" pyproject.toml, r2x will automatically detect and install all workspace");
println!(" members (e.g., packages in packages/*), registering their entry points.");
println!();
Ok(())
}
fn run_pip_install(
uv_path: &str,
python_path: &str,
package: &str,
editable: bool,
no_cache: bool,
) -> Result<(), PluginError> {
let install_args = build_pip_install_args(python_path, package, editable, no_cache);
uv::run(uv_path, "Installing", package, install_args)
.map(|_| ())
.map_err(PluginError::from)
}
fn build_pip_install_args(
python_path: &str,
package: &str,
editable: bool,
no_cache: bool,
) -> Vec<String> {
let mut install_args: Vec<String> = vec![
"pip".to_string(),
"install".to_string(),
"--python".to_string(),
python_path.to_string(),
"--prerelease=allow".to_string(),
];
if no_cache {
install_args.push("--no-cache".to_string());
}
if editable {
install_args.push("-e".to_string());
}
install_args.push(package.to_string());
install_args
}
fn print_install_summary(pkg: &str, version: &str, count: usize, elapsed: std::time::Duration) {
let elapsed_ms = elapsed.as_millis();
if count == 0 {
logger::status(&format!(
"Installed {pkg} and registered 0 plugin(s) in {elapsed_ms}ms"
));
return;
}
let package = if version.is_empty() {
pkg.to_string()
} else {
format!("{pkg}=={version}")
};
logger::status(&format!(
"Installed {package} and registered {count} plugin(s) in {elapsed_ms}ms"
));
}
fn is_workspace_package(package_spec: &str) -> Result<bool, PluginError> {
let is_local_path = crate::plugins::package_spec::is_local_path(package_spec);
let is_git_url = crate::plugins::package_spec::is_git_url(package_spec);
if !is_local_path && !is_git_url {
return Ok(false);
}
if is_local_path {
let pyproject_path = Path::new(package_spec).join("pyproject.toml");
if !pyproject_path.exists() {
return Ok(false);
}
let content = fs::read_to_string(&pyproject_path).map_err(|e| {
PluginError::PackageSpec(format!("Failed to read pyproject.toml: {}", e))
})?;
return Ok(content.contains("[tool.uv.workspace]"));
}
Ok(false)
}
fn discover_all_installed_packages(
ctx: &mut PluginContext,
no_cache: bool,
total_start: std::time::Instant,
) -> Result<(), PluginError> {
let discoverer = PackageDiscoverer::new(&ctx.locator);
let packages = discoverer.discover_packages();
if packages.is_empty() {
logger::warn("No packages with r2x_plugin entry points found");
logger::status(&format!(
"Discovered 0 package(s) with 0 plugin(s) in {}ms",
total_start.elapsed().as_millis()
));
return Ok(());
}
logger::info(&format!(
"Found {} package(s) with r2x_plugin entry points",
packages.len()
));
let mut discovered_count = 0;
let mut total_entry_points = 0;
for package in packages {
let package_name = package.name.clone();
logger::debug(&format!("Checking for plugins in: {}", package_name));
let (package_version, dependencies) =
match get_package_info(&ctx.uv_path, &ctx.python_path, &package_name) {
Ok((version, deps)) => (version, deps),
Err(_) => continue,
};
let source_path = package
.resolved_source_path
.as_ref()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.or_else(|| {
if package.is_editable {
Some(package.location.to_string_lossy().to_string())
} else {
None
}
});
if let Ok(entry_count) = discover_and_register_entry_points_with_deps(
&ctx.locator,
Some(&ctx.venv_path),
&mut ctx.manifest,
DiscoveryOptions {
package: package_name.clone(),
package_name_full: package_name.clone(),
dependencies,
package_version: package_version.clone(),
no_cache,
editable: package.is_editable,
source_path: source_path.clone(),
source_uri: source_path,
},
) {
if entry_count > 0 {
let version_str = package_version.as_deref().unwrap_or("");
let package = if version_str.is_empty() {
package_name.clone()
} else {
format!("{package_name}=={version_str}")
};
logger::status(&format!("Discovered plugin package: {package}"));
discovered_count += 1;
total_entry_points += entry_count;
}
} else {
}
}
let elapsed_ms = total_start.elapsed().as_millis();
logger::status(&format!(
"Discovered {discovered_count} package(s) with {total_entry_points} plugin(s) in {elapsed_ms}ms"
));
Ok(())
}
#[cfg(test)]
mod tests {
use super::build_pip_install_args;
#[test]
fn build_pip_install_args_supports_editable_and_no_cache() {
let args = build_pip_install_args("/tmp/python", "/tmp/plugin", true, true);
assert!(args.iter().any(|arg| arg == "-e"));
assert!(args.iter().any(|arg| arg == "--no-cache"));
assert!(!args.iter().any(|arg| arg == "--no-progress"));
}
}