use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::core::metadata::Metadata;
use crate::error::{MigrationExitCode, Result, ScoopError};
use crate::paths;
use crate::uv::PythonInfo;
use crate::uv::UvClient;
use crate::validate::PythonVersion;
use super::extractor::{ExtractionResult, PackageExtractor};
use super::source::{EnvironmentStatus, SourceEnvironment};
#[derive(Debug)]
pub enum PythonAvailability {
Available(PythonInfo),
Compatible {
requested: String,
available: PythonInfo,
},
CanInstall { version: String },
Unavailable { reason: String },
}
fn extract_major_minor(version: &str) -> String {
let parts: Vec<&str> = version.split('.').collect();
match parts.as_slice() {
[major, minor, ..] => format!("{}.{}", major, minor),
[major] => (*major).to_string(),
_ => version.to_string(),
}
}
#[derive(Debug, Clone, Default)]
pub struct MigrateOptions {
pub skip_packages: bool,
pub force: bool,
pub dry_run: bool,
pub rename_to: Option<String>,
pub strict: bool,
pub delete_source: bool,
pub auto_install_python: bool,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct MigrationResult {
pub name: String,
pub python_version: String,
pub packages_migrated: usize,
pub packages_failed: Vec<String>,
pub dry_run: bool,
pub path: PathBuf,
pub source_deleted: bool,
pub actual_python_version: String,
}
impl MigrationResult {
pub fn exit_code(&self) -> MigrationExitCode {
if self.packages_failed.is_empty() {
MigrationExitCode::Success
} else {
MigrationExitCode::PartialSuccess
}
}
}
struct RollbackGuard {
path: Option<PathBuf>,
}
impl RollbackGuard {
fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
fn disarm(&mut self) {
self.path = None;
}
}
impl Drop for RollbackGuard {
fn drop(&mut self) {
if let Some(path) = &self.path {
let _ = fs::remove_dir_all(path);
}
}
}
pub struct Migrator {
uv: UvClient,
extractor: PackageExtractor,
}
impl Migrator {
pub fn new() -> Result<Self> {
Ok(Self {
uv: UvClient::new()?,
extractor: PackageExtractor::new(),
})
}
pub fn with_uv(uv: UvClient) -> Self {
Self {
uv,
extractor: PackageExtractor::new(),
}
}
pub fn check_python_availability(&self, version: &str) -> Result<PythonAvailability> {
if let Some(info) = self.uv.find_python(version)? {
return Ok(PythonAvailability::Available(info));
}
let major_minor = extract_major_minor(version);
if let Some(info) = self.uv.find_python(&major_minor)? {
return Ok(PythonAvailability::Compatible {
requested: version.to_string(),
available: info,
});
}
let available = self.uv.list_pythons()?;
let can_install = PythonVersion::parse(&major_minor).is_some_and(|req| {
available
.iter()
.filter_map(|info| PythonVersion::parse(&info.version))
.any(|have| req.matches(&have))
});
if can_install {
Ok(PythonAvailability::CanInstall {
version: major_minor,
})
} else {
Ok(PythonAvailability::Unavailable {
reason: format!(
"Python {} is not available and cannot be installed",
version
),
})
}
}
fn validate_source(&self, source: &SourceEnvironment, options: &MigrateOptions) -> Result<()> {
match &source.status {
EnvironmentStatus::Ready => Ok(()),
EnvironmentStatus::NameConflict { existing } => {
if options.force {
Ok(())
} else {
Err(ScoopError::MigrationNameConflict {
name: source.name.clone(),
existing: existing.clone(),
})
}
}
EnvironmentStatus::PythonEol { version } => {
if options.force {
Ok(())
} else {
Err(ScoopError::MigrationFailed {
reason: format!(
"Python {} is end-of-life. Use --force to migrate anyway.",
version
),
})
}
}
EnvironmentStatus::Corrupted { reason } => Err(ScoopError::CorruptedEnvironment {
name: source.name.clone(),
reason: reason.clone(),
}),
}
}
fn extract_packages(&self, source: &SourceEnvironment) -> Result<ExtractionResult> {
self.extractor.extract(&source.path)
}
fn create_target_env(&self, name: &str, python_version: &str, force: bool) -> Result<PathBuf> {
crate::validate::validate_env_name(name)?;
let target_path = paths::virtualenv_path(name)?;
if target_path.exists() {
if force {
fs::remove_dir_all(&target_path)?;
} else {
return Err(ScoopError::VirtualenvExists {
name: name.to_string(),
});
}
}
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}
self.uv.create_venv(&target_path, python_version)?;
Ok(target_path)
}
fn install_packages(
&self,
target_path: &Path,
packages: &ExtractionResult,
strict: bool,
) -> Result<Vec<String>> {
let mut failed = Vec::new();
let regular_specs: Vec<String> = packages
.regular_packages()
.iter()
.map(|p| p.to_requirement())
.collect();
if !regular_specs.is_empty() {
if let Err(e) = self.uv.pip_install(target_path, ®ular_specs) {
for spec in ®ular_specs {
if self
.uv
.pip_install(target_path, std::slice::from_ref(spec))
.is_err()
{
if strict {
return Err(ScoopError::MigrationFailed {
reason: format!("Failed to install package: {}", spec),
});
}
failed.push(spec.clone());
}
}
if failed.len() == regular_specs.len() {
return Err(e);
}
}
}
for editable in packages.editable_packages() {
failed.push(format!(
"{} (editable - skipped)",
editable.to_requirement()
));
}
Ok(failed)
}
pub fn delete_source(&self, source: &SourceEnvironment) -> Result<()> {
if !source.path.exists() {
return Ok(()); }
fs::remove_dir_all(&source.path).map_err(|e| {
ScoopError::Io(std::io::Error::new(
e.kind(),
format!(
"Failed to delete source at {}: {}",
source.path.display(),
e
),
))
})
}
fn write_metadata(&self, target_path: &Path, name: &str, python_version: &str) -> Result<()> {
let uv_version = self.uv.version().ok();
let metadata = Metadata::new(name.to_string(), python_version.to_string(), uv_version);
let metadata_path = target_path.join(".scoop-metadata.json");
let content = serde_json::to_string_pretty(&metadata)?;
fs::write(metadata_path, content)?;
Ok(())
}
pub fn migrate(
&self,
source: &SourceEnvironment,
options: &MigrateOptions,
) -> Result<MigrationResult> {
let target_name = options.rename_to.as_ref().unwrap_or(&source.name).clone();
if options.dry_run {
let packages = self.extract_packages(source)?;
let target_path = paths::virtualenv_path(&target_name)?;
return Ok(MigrationResult {
name: target_name,
python_version: source.python_version.clone(),
packages_migrated: packages.packages.len(),
packages_failed: packages.failed.clone(),
dry_run: true,
path: target_path,
source_deleted: false,
actual_python_version: source.python_version.clone(),
});
}
self.validate_source(source, options)?;
let packages = if options.skip_packages {
ExtractionResult {
packages: Vec::new(),
failed: Vec::new(),
total_found: 0,
}
} else {
self.extract_packages(source)?
};
let target_path =
self.create_target_env(&target_name, &source.python_version, options.force)?;
let mut rollback = RollbackGuard::new(target_path.clone());
let failed = if options.skip_packages {
Vec::new()
} else {
self.install_packages(&target_path, &packages, options.strict)?
};
self.write_metadata(&target_path, &target_name, &source.python_version)?;
rollback.disarm();
let source_deleted = if options.delete_source {
self.delete_source(source)?;
true
} else {
false
};
let packages_migrated = packages.packages.len() - failed.len();
Ok(MigrationResult {
name: target_name,
python_version: source.python_version.clone(),
packages_migrated,
packages_failed: failed,
dry_run: false,
path: target_path,
source_deleted,
actual_python_version: source.python_version.clone(),
})
}
pub fn migrate_all(
&self,
sources: &[SourceEnvironment],
options: &MigrateOptions,
) -> Vec<Result<MigrationResult>> {
sources
.iter()
.map(|source| self.migrate(source, options))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::migrate::source::SourceType;
fn mock_source(name: &str, status: EnvironmentStatus) -> SourceEnvironment {
SourceEnvironment {
name: name.to_string(),
python_version: "3.12.0".to_string(),
path: PathBuf::from("/mock/path"),
source_type: SourceType::Pyenv,
size_bytes: Some(1024),
status,
}
}
#[test]
fn create_target_env_rejects_path_traversal_name() {
let migrator = Migrator {
uv: UvClient::with_path(PathBuf::from("/mock/uv")),
extractor: PackageExtractor::new(),
};
for evil in ["../../etc/evil", "..", "foo/bar", "/abs", ".hidden"] {
let result = migrator.create_target_env(evil, "3.12", false);
assert!(
matches!(result, Err(ScoopError::InvalidEnvName { .. })),
"name {evil:?} should be rejected as invalid"
);
}
}
#[test]
fn test_validate_source_ready() {
let migrator = Migrator {
uv: UvClient::with_path(PathBuf::from("/mock/uv")),
extractor: PackageExtractor::new(),
};
let source = mock_source("test", EnvironmentStatus::Ready);
let options = MigrateOptions::default();
assert!(migrator.validate_source(&source, &options).is_ok());
}
#[test]
fn test_validate_source_corrupted() {
let migrator = Migrator {
uv: UvClient::with_path(PathBuf::from("/mock/uv")),
extractor: PackageExtractor::new(),
};
let source = mock_source(
"test",
EnvironmentStatus::Corrupted {
reason: "broken".to_string(),
},
);
let options = MigrateOptions::default();
assert!(migrator.validate_source(&source, &options).is_err());
}
#[test]
fn test_validate_source_name_conflict_with_force() {
let migrator = Migrator {
uv: UvClient::with_path(PathBuf::from("/mock/uv")),
extractor: PackageExtractor::new(),
};
let source = mock_source(
"test",
EnvironmentStatus::NameConflict {
existing: PathBuf::from("/existing"),
},
);
let options = MigrateOptions {
force: true,
..Default::default()
};
assert!(migrator.validate_source(&source, &options).is_ok());
}
#[test]
fn test_validate_source_eol_without_force() {
let migrator = Migrator {
uv: UvClient::with_path(PathBuf::from("/mock/uv")),
extractor: PackageExtractor::new(),
};
let source = mock_source(
"test",
EnvironmentStatus::PythonEol {
version: "3.7.0".to_string(),
},
);
let options = MigrateOptions::default();
assert!(migrator.validate_source(&source, &options).is_err());
}
#[test]
fn test_extract_major_minor_full_version() {
assert_eq!(extract_major_minor("3.12.1"), "3.12");
assert_eq!(extract_major_minor("3.9.18"), "3.9");
assert_eq!(extract_major_minor("2.7.18"), "2.7");
}
#[test]
fn test_extract_major_minor_partial_version() {
assert_eq!(extract_major_minor("3.12"), "3.12");
assert_eq!(extract_major_minor("3"), "3");
}
#[test]
fn test_extract_major_minor_edge_cases() {
assert_eq!(extract_major_minor(""), "");
assert_eq!(extract_major_minor("3.12.1.post1"), "3.12");
}
}