scoop-uv 0.14.0

Scoop up your Python envs — pyenv-style workflow powered by uv
Documentation
//! Environment scanning and discovery
//!
//! Discovers environments from pyenv, virtualenvwrapper, and conda sources.

use crate::cli::MigrateSource;
use crate::core::migrate::{
    CondaDiscovery, EnvironmentSource, PyenvDiscovery, SourceEnvironment, SourceType,
    VenvWrapperDiscovery,
};
use crate::error::{Result, ScoopError};

/// Scan environments from all available sources or a specific source.
///
/// Results are sorted by source type (pyenv, virtualenvwrapper, conda),
/// then alphabetically by name.
///
/// # Examples
///
/// ```text
/// // Scan all sources (returns empty if none installed)
/// let all_envs = scan_all_environments(None);
/// println!("Found {} environments", all_envs.len());
///
/// // Scan only pyenv
/// let pyenv_only = scan_all_environments(Some(MigrateSource::Pyenv));
/// for env in pyenv_only {
///     println!("{}: Python {}", env.name, env.python_version);
/// }
/// ```
pub fn scan_all_environments(source_filter: Option<MigrateSource>) -> Vec<SourceEnvironment> {
    let mut all_envs = Vec::new();

    // Scan sources based on filter
    let scan_pyenv = source_filter.is_none() || source_filter == Some(MigrateSource::Pyenv);
    let scan_venv =
        source_filter.is_none() || source_filter == Some(MigrateSource::Virtualenvwrapper);
    let scan_conda = source_filter.is_none() || source_filter == Some(MigrateSource::Conda);

    if scan_pyenv {
        if let Some(discovery) = PyenvDiscovery::default_root() {
            if let Ok(envs) = discovery.scan_environments() {
                all_envs.extend(envs);
            }
        }
    }

    if scan_venv {
        if let Some(discovery) = VenvWrapperDiscovery::default_root() {
            if let Ok(envs) = discovery.scan_environments() {
                all_envs.extend(envs);
            }
        }
    }

    if scan_conda {
        if let Some(discovery) = CondaDiscovery::default_roots() {
            if let Ok(envs) = discovery.scan_environments() {
                all_envs.extend(envs);
            }
        }
    }

    // Sort by source type, then by name
    all_envs.sort_by(|a, b| {
        let source_order = |s: &SourceType| match s {
            SourceType::Pyenv => 0,
            SourceType::VirtualenvWrapper => 1,
            SourceType::Conda => 2,
        };
        source_order(&a.source_type)
            .cmp(&source_order(&b.source_type))
            .then(a.name.cmp(&b.name))
    });

    all_envs
}

/// Returns `true` if at least one source tool matching `filter` is
/// installed on this system (i.e. its `default_root()` /
/// `default_roots()` returned `Some`).
///
/// Used by `batch.rs` to distinguish two empty-list cases:
///
/// - **No tools installed** — `scan_all_environments` returned an empty
///   vec because no source tool exists. Surfaces as
///   [`ScoopError::MigrationSourcesNotFound`] (exit 3) so CI scripts
///   can detect a misconfigured environment.
/// - **Tools present but no envs** — current `Ok(())` behaviour stays.
///
/// `filter == None` checks all three sources; a specific `MigrateSource`
/// only checks that one.
pub fn any_source_tool_available(filter: Option<MigrateSource>) -> bool {
    let want_pyenv = filter.is_none() || filter == Some(MigrateSource::Pyenv);
    let want_venv = filter.is_none() || filter == Some(MigrateSource::Virtualenvwrapper);
    let want_conda = filter.is_none() || filter == Some(MigrateSource::Conda);

    (want_pyenv && PyenvDiscovery::default_root().is_some())
        || (want_venv && VenvWrapperDiscovery::default_root().is_some())
        || (want_conda && CondaDiscovery::default_roots().is_some())
}

/// Find an environment by name, searching across sources.
///
/// Searches in order: pyenv, virtualenvwrapper, conda.
/// Returns the first match found.
///
/// # Examples
///
/// ```text
/// // Search for non-existent environment returns error
/// let result = find_environment_by_name("nonexistent-env-12345", None);
/// assert!(result.is_err());
///
/// // Search only in specific source
/// let result = find_environment_by_name("myproject", Some(MigrateSource::Pyenv));
/// // Returns Ok(env) if found, Err if not
/// ```
///
/// # Errors
///
/// Returns source-specific error if environment is not found:
/// - [`ScoopError::PyenvEnvNotFound`]
/// - [`ScoopError::VenvWrapperEnvNotFound`]
/// - [`ScoopError::CondaEnvNotFound`]
pub fn find_environment_by_name(
    name: &str,
    source_filter: Option<MigrateSource>,
) -> Result<SourceEnvironment> {
    // Try pyenv first
    if source_filter.is_none() || source_filter == Some(MigrateSource::Pyenv) {
        if let Some(discovery) = PyenvDiscovery::default_root() {
            if let Ok(env) = discovery.find_environment(name) {
                return Ok(env);
            }
        }
    }

    // Try virtualenvwrapper
    if source_filter.is_none() || source_filter == Some(MigrateSource::Virtualenvwrapper) {
        if let Some(discovery) = VenvWrapperDiscovery::default_root() {
            if let Ok(env) = discovery.find_environment(name) {
                return Ok(env);
            }
        }
    }

    // Try conda
    if source_filter.is_none() || source_filter == Some(MigrateSource::Conda) {
        if let Some(discovery) = CondaDiscovery::default_roots() {
            if let Ok(env) = discovery.find_environment(name) {
                return Ok(env);
            }
        }
    }

    // If a specific source was requested, return that error
    match source_filter {
        Some(MigrateSource::Pyenv) => Err(ScoopError::PyenvEnvNotFound {
            name: name.to_string(),
        }),
        Some(MigrateSource::Virtualenvwrapper) => Err(ScoopError::VenvWrapperEnvNotFound {
            name: name.to_string(),
        }),
        Some(MigrateSource::Conda) => Err(ScoopError::CondaEnvNotFound {
            name: name.to_string(),
        }),
        None => Err(ScoopError::PyenvEnvNotFound {
            name: name.to_string(),
        }),
    }
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_utils::with_isolated_migrate_env;
    use serial_test::serial;

    /// Tests that source_filter=None returns PyenvEnvNotFound for nonexistent env.
    #[test]
    fn find_environment_by_name_no_filter_returns_pyenv_error() {
        with_isolated_migrate_env(|| {
            let result = find_environment_by_name("nonexistent_env_12345", None);

            assert!(result.is_err());
            assert!(matches!(
                result.unwrap_err(),
                ScoopError::PyenvEnvNotFound { .. }
            ));
        });
    }

    /// Tests that source_filter=Pyenv returns PyenvEnvNotFound.
    #[test]
    fn find_environment_by_name_pyenv_filter_returns_pyenv_error() {
        with_isolated_migrate_env(|| {
            let result =
                find_environment_by_name("nonexistent_env_12345", Some(MigrateSource::Pyenv));

            assert!(result.is_err());
            let err = result.unwrap_err();
            assert!(
                matches!(err, ScoopError::PyenvEnvNotFound { ref name } if name == "nonexistent_env_12345")
            );
        });
    }

    /// Tests that source_filter=Virtualenvwrapper returns VenvWrapperEnvNotFound.
    #[test]
    fn find_environment_by_name_venvwrapper_filter_returns_venvwrapper_error() {
        with_isolated_migrate_env(|| {
            let result = find_environment_by_name(
                "nonexistent_env_12345",
                Some(MigrateSource::Virtualenvwrapper),
            );

            assert!(result.is_err());
            let err = result.unwrap_err();
            assert!(
                matches!(err, ScoopError::VenvWrapperEnvNotFound { ref name } if name == "nonexistent_env_12345")
            );
        });
    }

    /// Tests that source_filter=Conda returns CondaEnvNotFound.
    #[test]
    fn find_environment_by_name_conda_filter_returns_conda_error() {
        with_isolated_migrate_env(|| {
            let result =
                find_environment_by_name("nonexistent_env_12345", Some(MigrateSource::Conda));

            assert!(result.is_err());
            let err = result.unwrap_err();
            assert!(
                matches!(err, ScoopError::CondaEnvNotFound { ref name } if name == "nonexistent_env_12345")
            );
        });
    }

    /// Tests scan_all_environments with no environments installed returns empty.
    #[test]
    fn scan_all_environments_empty_when_no_sources() {
        with_isolated_migrate_env(|| {
            let envs = scan_all_environments(None);
            assert!(envs.is_empty());
        });
    }

    /// Tests scan_all_environments with specific source filter.
    #[test]
    fn scan_all_environments_with_pyenv_filter_returns_empty() {
        with_isolated_migrate_env(|| {
            let envs = scan_all_environments(Some(MigrateSource::Pyenv));
            assert!(envs.is_empty());
        });
    }

    #[test]
    fn scan_all_environments_with_venvwrapper_filter_returns_empty() {
        with_isolated_migrate_env(|| {
            let envs = scan_all_environments(Some(MigrateSource::Virtualenvwrapper));
            assert!(envs.is_empty());
        });
    }

    #[test]
    fn scan_all_environments_with_conda_filter_returns_empty() {
        with_isolated_migrate_env(|| {
            let envs = scan_all_environments(Some(MigrateSource::Conda));
            assert!(envs.is_empty());
        });
    }

    // =========================================================================
    // any_source_tool_available Tests (Inc 4)
    // =========================================================================

    /// With no source tools installed (isolated tempdir as $HOME), the
    /// availability probe returns false. This is the signal `batch.rs`
    /// uses to surface MigrationSourcesNotFound (exit 3) instead of
    /// silently succeeding when there are simply no envs.
    #[test]
    #[serial]
    fn any_source_tool_available_returns_false_when_isolated() {
        with_isolated_migrate_env(|| {
            assert!(!any_source_tool_available(None));
            assert!(!any_source_tool_available(Some(MigrateSource::Pyenv)));
            assert!(!any_source_tool_available(Some(
                MigrateSource::Virtualenvwrapper
            )));
            assert!(!any_source_tool_available(Some(MigrateSource::Conda)));
        });
    }

    /// When `with_full_migrate_env` materialises a pyenv tree, the Pyenv
    /// branch of the OR-chain matches and the probe returns true. The
    /// other filters still come back false because only pyenv is set up.
    #[test]
    #[serial]
    fn any_source_tool_available_returns_true_when_pyenv_present() {
        use crate::test_utils::with_full_migrate_env;
        with_full_migrate_env(|_scoop, _pyenv| {
            assert!(any_source_tool_available(None));
            assert!(any_source_tool_available(Some(MigrateSource::Pyenv)));
        });
    }
}