r2x 0.1.9

A framework plugin manager for the r2x power systems modeling ecosystem.
Documentation
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;

/// Options for git-based package installation
pub struct GitOptions {
    pub host: Option<String>,
    pub branch: Option<String>,
    pub tag: Option<String>,
    pub commit: Option<String>,
}

/// Install a plugin package
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(),
    )?;

    // Check if this is a workspace installation
    if is_workspace_package(&package_spec)? {
        logger::status("Detected workspace repository, installing all members...");
        // Just install the workspace - uv will handle all members
        run_pip_install(
            &ctx.uv_path,
            &ctx.python_path,
            &package_spec,
            editable,
            no_cache,
        )?;
        ctx.refresh_locator()?;

        // Now discover all packages with entry points (like sync command).
        // The install transaction just mutated site-packages, so rebuild plugin
        // metadata from disk instead of trusting any existing manifest entries.
        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),
        }

        // uv pip install mutates site-packages and dist-info directories. Refresh the
        // locator cache so entry_points.txt discovery can see newly installed packages.
        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()));

    // source_path: local filesystem path for AST discovery (editable installs only)
    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
    };

    // source_uri: display origin stored in manifest (git URL or local path)
    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"
    ));
}

/// Check if a package is a workspace (by detecting [tool.uv.workspace] in pyproject.toml)
fn is_workspace_package(package_spec: &str) -> Result<bool, PluginError> {
    // Only check for local paths or git URLs
    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);
    }

    // For local paths, check directly
    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]"));
    }

    // Git URLs: can't inspect pyproject.toml without cloning; dependency
    // discovery in the normal install flow handles workspace members.
    Ok(false)
}

/// Discover all installed packages with r2x_plugin entry points
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));

        // Get package info
        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
                }
            });

        // Try to discover entry points
        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 {
            // Not every package has r2x_plugin entry points, skip silently
        }
    }

    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"));
    }
}