diff --git a/crates/pet-core/src/python_environment.rs b/crates/pet-core/src/python_environment.rs index f2f90c91..7bbfe5b8 100644 --- a/crates/pet-core/src/python_environment.rs +++ b/crates/pet-core/src/python_environment.rs @@ -415,13 +415,16 @@ pub fn get_environment_key(env: &PythonEnvironment) -> Option { if let Some(exe) = &env.executable { Some(exe.clone()) } else if let Some(prefix) = &env.prefix { - // If this is a conda env without Python, then the exe will be prefix/bin/python + // If this is a conda env without Python, use the platform's default interpreter path. if env.kind == Some(PythonEnvironmentKind::Conda) { - Some(prefix.join("bin").join(if cfg!(windows) { - "python.exe" - } else { - "python" - })) + #[cfg(windows)] + { + Some(prefix.join("python.exe")) + } + #[cfg(not(windows))] + { + Some(prefix.join("bin").join("python")) + } } else { Some(prefix.clone()) } @@ -436,11 +439,12 @@ pub fn get_environment_key(env: &PythonEnvironment) -> Option { #[cfg(test)] mod tests { - #[cfg(windows)] - use super::{get_shortest_executable, PythonEnvironmentKind}; - #[cfg(windows)] + use super::{get_environment_key, PythonEnvironment, PythonEnvironmentKind}; use std::path::PathBuf; + #[cfg(windows)] + use super::get_shortest_executable; + #[test] #[cfg(windows)] fn shorted_exe_path_windows_store() { @@ -459,4 +463,64 @@ mod tests { )) ); } + + #[test] + fn environment_key_uses_executable_when_available() { + let executable = PathBuf::from(if cfg!(windows) { + r"C:\env\Scripts\python.exe" + } else { + "/env/bin/python" + }); + let prefix = PathBuf::from(if cfg!(windows) { r"C:\env" } else { "/env" }); + let environment = PythonEnvironment { + executable: Some(executable.clone()), + prefix: Some(prefix), + kind: Some(PythonEnvironmentKind::Venv), + ..Default::default() + }; + + assert_eq!(get_environment_key(&environment), Some(executable)); + } + + #[test] + fn environment_key_uses_conda_default_python_when_executable_is_missing() { + let prefix = PathBuf::from(if cfg!(windows) { + r"C:\conda-env" + } else { + "/conda-env" + }); + let environment = PythonEnvironment { + executable: None, + prefix: Some(prefix.clone()), + kind: Some(PythonEnvironmentKind::Conda), + ..Default::default() + }; + + assert_eq!( + get_environment_key(&environment), + Some(if cfg!(windows) { + prefix.join("python.exe") + } else { + prefix.join("bin").join("python") + }) + ); + } + + #[test] + fn environment_key_uses_non_conda_prefix_when_executable_is_missing() { + let prefix = PathBuf::from(if cfg!(windows) { r"C:\env" } else { "/env" }); + let environment = PythonEnvironment { + executable: None, + prefix: Some(prefix.clone()), + kind: Some(PythonEnvironmentKind::Venv), + ..Default::default() + }; + + assert_eq!(get_environment_key(&environment), Some(prefix)); + } + + #[test] + fn environment_key_returns_none_without_executable_or_prefix() { + assert_eq!(get_environment_key(&PythonEnvironment::default()), None); + } } diff --git a/crates/pet-env-var-path/src/lib.rs b/crates/pet-env-var-path/src/lib.rs index d6a91f19..89746b39 100644 --- a/crates/pet-env-var-path/src/lib.rs +++ b/crates/pet-env-var-path/src/lib.rs @@ -3,29 +3,47 @@ use pet_core::os_environment::Environment; use std::collections::HashSet; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub fn get_search_paths_from_env_variables(environment: &dyn Environment) -> Vec { - // Exclude files from this folder, as they would have been discovered elsewhere (widows_store) + let search_paths = environment + .get_know_global_search_locations() + .into_iter() + .map(normalize_search_path) + .collect::>(); + + // Exclude files from this folder, as they would have been discovered elsewhere (windows_store) // Also the exe is merely a pointer to another file. - if let Some(home) = environment.get_user_home() { + let user_home = environment.get_user_home(); + search_paths + .into_iter() + .filter(|search_path| !is_windows_apps_path(search_path, user_home.as_ref())) + .collect() +} + +fn is_windows_apps_path(search_path: &Path, user_home: Option<&PathBuf>) -> bool { + if let Some(home) = user_home { let apps_path = home .join("AppData") .join("Local") .join("Microsoft") .join("WindowsApps"); - - environment - .get_know_global_search_locations() - .into_iter() - .map(normalize_search_path) - .collect::>() - .into_iter() - .filter(|p| !p.starts_with(apps_path.clone())) - .collect() - } else { - Vec::new() + if search_path.starts_with(apps_path) { + return true; + } } + + let components = search_path + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>(); + + components.windows(4).any(|components| { + components[0].eq_ignore_ascii_case("AppData") + && components[1].eq_ignore_ascii_case("Local") + && components[2].eq_ignore_ascii_case("Microsoft") + && components[3].eq_ignore_ascii_case("WindowsApps") + }) } /// Normalizes a search path for deduplication purposes. @@ -52,3 +70,97 @@ fn normalize_search_path(path: PathBuf) -> PathBuf { pet_fs::path::norm_case(&path) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + struct TestEnvironment { + user_home: Option, + global_search_locations: Vec, + } + + impl Environment for TestEnvironment { + fn get_user_home(&self) -> Option { + self.user_home.clone() + } + + fn get_root(&self) -> Option { + None + } + + fn get_env_var(&self, _key: String) -> Option { + None + } + + fn get_know_global_search_locations(&self) -> Vec { + self.global_search_locations.clone() + } + } + + fn create_test_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "pet-env-var-path-{name}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + directory + } + + #[test] + fn search_paths_are_deduplicated_and_windows_apps_paths_are_filtered() { + let home = create_test_dir("home"); + let regular_path = home.join("Python"); + let windows_apps_path = home + .join("AppData") + .join("Local") + .join("Microsoft") + .join("WindowsApps"); + fs::create_dir_all(®ular_path).unwrap(); + fs::create_dir_all(&windows_apps_path).unwrap(); + + let environment = TestEnvironment { + user_home: Some(home.clone()), + global_search_locations: vec![ + regular_path.clone(), + regular_path.clone(), + windows_apps_path, + ], + }; + + let mut search_paths = get_search_paths_from_env_variables(&environment); + search_paths.sort(); + + assert_eq!(search_paths, vec![normalize_search_path(regular_path)]); + + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn search_paths_are_preserved_when_home_is_unknown() { + let environment = TestEnvironment { + user_home: None, + global_search_locations: vec![ + PathBuf::from("/usr/bin"), + PathBuf::from(if cfg!(windows) { + r"C:\Users\User\AppData\Local\Microsoft\WindowsApps" + } else { + "/Users/user/AppData/Local/Microsoft/WindowsApps" + }), + ], + }; + + assert_eq!( + get_search_paths_from_env_variables(&environment), + vec![normalize_search_path(PathBuf::from("/usr/bin"))] + ); + } +} diff --git a/crates/pet-global-virtualenvs/src/lib.rs b/crates/pet-global-virtualenvs/src/lib.rs index 1a01053f..616b823a 100644 --- a/crates/pet-global-virtualenvs/src/lib.rs +++ b/crates/pet-global-virtualenvs/src/lib.rs @@ -41,11 +41,7 @@ fn get_global_virtualenv_dirs( if cfg!(target_os = "linux") { // https://virtualenvwrapper.readthedocs.io/en/latest/index.html // Default recommended location for virtualenvwrapper - let envs = PathBuf::from("Envs"); - if envs.exists() { - venv_dirs.push(envs); - } - let envs = PathBuf::from("envs"); + let envs = home.join("Envs"); if envs.exists() { venv_dirs.push(envs); } @@ -87,3 +83,134 @@ pub fn list_global_virtual_envs_paths( python_envs } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn create_test_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "pet-global-virtualenvs-{name}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + directory + } + + #[test] + fn global_virtualenv_dirs_include_existing_configured_and_default_locations() { + let root = create_test_dir("dirs"); + let work_on_home = root.join("workon-home"); + let xdg_virtualenvs = root.join("xdg-data").join("virtualenvs"); + let local_virtualenvs = root.join(".local").join("share").join("virtualenvs"); + let missing_work_on_home = root.join("missing-workon-home"); + fs::create_dir_all(&work_on_home).unwrap(); + fs::create_dir_all(&xdg_virtualenvs).unwrap(); + fs::create_dir_all(root.join("envs")).unwrap(); + fs::create_dir_all(root.join(".direnv")).unwrap(); + fs::create_dir_all(root.join(".venvs")).unwrap(); + fs::create_dir_all(root.join(".virtualenvs")).unwrap(); + fs::create_dir_all(&local_virtualenvs).unwrap(); + + #[cfg(target_os = "linux")] + { + fs::create_dir_all(root.join("Envs")).unwrap(); + } + + let mut dirs = get_global_virtualenv_dirs( + Some(missing_work_on_home.to_string_lossy().to_string()), + Some(root.join("xdg-data").to_string_lossy().to_string()), + Some(root.clone()), + ); + dirs.sort(); + + #[cfg(not(target_os = "linux"))] + let expected_dirs = vec![ + root.join(".direnv"), + root.join(".local").join("share").join("virtualenvs"), + root.join(".venvs"), + root.join(".virtualenvs"), + root.join("envs"), + xdg_virtualenvs, + ]; + + #[cfg(target_os = "linux")] + let mut expected_dirs = vec![ + root.join(".direnv"), + root.join(".local").join("share").join("virtualenvs"), + root.join(".venvs"), + root.join(".virtualenvs"), + root.join("envs"), + xdg_virtualenvs, + root.join("Envs"), + ]; + + #[cfg(target_os = "linux")] + { + expected_dirs.sort(); + } + + assert_eq!(dirs, expected_dirs); + + let dirs = get_global_virtualenv_dirs( + Some(work_on_home.to_string_lossy().to_string()), + None, + None, + ); + + assert_eq!(dirs, vec![norm_case(work_on_home)]); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn global_virtualenv_paths_include_virtual_env_var_and_non_conda_children_only() { + let root = create_test_dir("envs"); + let virtual_env = root.join("active-venv"); + let work_on_home = root.join("workon-home"); + let venv = work_on_home.join("plain-venv"); + let conda_env = work_on_home.join("conda-env"); + fs::create_dir_all(&virtual_env).unwrap(); + fs::create_dir_all(&venv).unwrap(); + fs::create_dir_all(conda_env.join("conda-meta")).unwrap(); + + let mut python_envs = list_global_virtual_envs_paths( + Some(virtual_env.to_string_lossy().to_string()), + Some(work_on_home.to_string_lossy().to_string()), + None, + Some(root.clone()), + ); + python_envs.sort(); + + assert_eq!(python_envs, vec![norm_case(virtual_env), norm_case(venv)]); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn global_virtualenv_paths_are_deduplicated_and_ignore_missing_inputs() { + let root = create_test_dir("dedupe-envs"); + let work_on_home = root.join("workon-home"); + let venv = work_on_home.join("plain-venv"); + fs::create_dir_all(&venv).unwrap(); + + let python_envs = list_global_virtual_envs_paths( + Some(venv.to_string_lossy().to_string()), + Some(work_on_home.to_string_lossy().to_string()), + Some(root.join("missing-xdg-data").to_string_lossy().to_string()), + Some(root.join("missing-home")), + ); + + assert_eq!(python_envs, vec![norm_case(venv)]); + + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/pet-jsonrpc/src/server.rs b/crates/pet-jsonrpc/src/server.rs index 40406934..e3d447d6 100644 --- a/crates/pet-jsonrpc/src/server.rs +++ b/crates/pet-jsonrpc/src/server.rs @@ -11,11 +11,13 @@ use std::{ type RequestHandler = Arc, u32, Value)>; type NotificationHandler = Arc, Value)>; +type ErrorHandler = Arc, i32, String)>; pub struct HandlersKeyedByMethodName { context: Arc, requests: HashMap<&'static str, RequestHandler>, notifications: HashMap<&'static str, NotificationHandler>, + send_error: ErrorHandler, } impl HandlersKeyedByMethodName { @@ -24,6 +26,20 @@ impl HandlersKeyedByMethodName { context, requests: HashMap::new(), notifications: HashMap::new(), + send_error: Arc::new(send_error), + } + } + + #[cfg(test)] + fn new_with_error_handler( + context: Arc, + send_error: impl Fn(Option, i32, String) + 'static, + ) -> Self { + HandlersKeyedByMethodName { + context, + requests: HashMap::new(), + notifications: HashMap::new(), + send_error: Arc::new(send_error), } } @@ -59,7 +75,7 @@ impl HandlersKeyedByMethodName { handler(self.context.clone(), id as u32, message["params"].clone()); } else { eprint!("Failed to find handler for method: {method}"); - send_error( + (self.send_error)( Some(id as u32), -1, format!("Failed to find handler for request {method}"), @@ -71,18 +87,13 @@ impl HandlersKeyedByMethodName { handler(self.context.clone(), message["params"].clone()); } else { eprint!("Failed to find handler for method: {method}"); - send_error( - None, - -2, - format!("Failed to find handler for notification {method}"), - ); } } } None => { eprint!("Failed to get method from message: {message}"); - send_error( - None, + (self.send_error)( + message["id"].as_u64().map(|id| id as u32), -3, format!("Failed to extract method from JSONRPC payload {message:?}"), ); @@ -150,3 +161,159 @@ fn get_content_length(line: &str) -> Result { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::Mutex; + + #[derive(Default)] + struct TestContext { + request: Mutex>, + notification: Mutex>, + errors: Mutex, i32, String)>>, + } + + fn create_handlers_with_recorded_errors( + context: Arc, + ) -> HandlersKeyedByMethodName { + let error_context = context.clone(); + HandlersKeyedByMethodName::new_with_error_handler(context, move |id, code, message| { + error_context + .errors + .lock() + .unwrap() + .push((id, code, message)); + }) + } + + #[test] + fn get_content_length_parses_valid_header() { + assert_eq!(get_content_length("Content-Length: 42\r\n").unwrap(), 42); + } + + #[test] + fn get_content_length_rejects_missing_header() { + let error = get_content_length("Content-Type: application/json\r\n").unwrap_err(); + + assert!(error.contains("String 'Content-Length' not found")); + } + + #[test] + fn get_content_length_rejects_non_numeric_length() { + let error = get_content_length("Content-Length: nope\r\n").unwrap_err(); + + assert!(error.contains("Failed to parse content length")); + } + + #[test] + fn handle_request_routes_request_and_notification_messages() { + let context = Arc::new(TestContext::default()); + let mut handlers = HandlersKeyedByMethodName::new(context.clone()); + handlers.add_request_handler("request/method", |context, id, params| { + *context.request.lock().unwrap() = Some((id, params)); + }); + handlers.add_notification_handler("notification/method", |context, params| { + *context.notification.lock().unwrap() = Some(params); + }); + + handlers.handle_request(json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "request/method", + "params": { "value": 42 } + })); + handlers.handle_request(json!({ + "jsonrpc": "2.0", + "method": "notification/method", + "params": ["item"] + })); + + assert_eq!( + *context.request.lock().unwrap(), + Some((7, json!({ "value": 42 }))) + ); + assert_eq!(*context.notification.lock().unwrap(), Some(json!(["item"]))); + } + + #[test] + fn handle_request_reports_unknown_methods_without_invoking_known_handlers() { + let context = Arc::new(TestContext::default()); + let mut handlers = create_handlers_with_recorded_errors(context.clone()); + handlers.add_request_handler("known/request", |context, id, params| { + *context.request.lock().unwrap() = Some((id, params)); + }); + handlers.add_notification_handler("known/notification", |context, params| { + *context.notification.lock().unwrap() = Some(params); + }); + + handlers.handle_request(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "unknown/request", + "params": null + })); + handlers.handle_request(json!({ + "jsonrpc": "2.0", + "method": "unknown/notification", + "params": null + })); + + assert!(context.request.lock().unwrap().is_none()); + assert!(context.notification.lock().unwrap().is_none()); + assert_eq!( + context.errors.lock().unwrap().as_slice(), + &[( + Some(1), + -1, + "Failed to find handler for request unknown/request".to_string() + )] + ); + } + + #[test] + fn handle_request_reports_missing_method_with_request_id() { + let context = Arc::new(TestContext::default()); + let handlers = create_handlers_with_recorded_errors(context.clone()); + + let message = json!({ + "jsonrpc": "2.0", + "id": 1, + "params": { "value": 42 } + }); + + handlers.handle_request(message.clone()); + + assert_eq!( + context.errors.lock().unwrap().as_slice(), + &[( + Some(1), + -3, + format!("Failed to extract method from JSONRPC payload {message:?}") + )] + ); + } + + #[test] + fn handle_request_reports_missing_method_with_null_id() { + let context = Arc::new(TestContext::default()); + let handlers = create_handlers_with_recorded_errors(context.clone()); + + let message = json!({ + "jsonrpc": "2.0", + "params": { "value": 42 } + }); + + handlers.handle_request(message.clone()); + + assert_eq!( + context.errors.lock().unwrap().as_slice(), + &[( + None, + -3, + format!("Failed to extract method from JSONRPC payload {message:?}") + )] + ); + } +} diff --git a/crates/pet-pixi/src/lib.rs b/crates/pet-pixi/src/lib.rs index b7adfde9..e6841a68 100644 --- a/crates/pet-pixi/src/lib.rs +++ b/crates/pet-pixi/src/lib.rs @@ -85,3 +85,139 @@ impl Locator for Pixi { fn find(&self, _reporter: &dyn Reporter) {} } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn create_test_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = + std::env::temp_dir().join(format!("pet-pixi-{name}-{}-{unique}", std::process::id())); + fs::create_dir_all(&directory).unwrap(); + directory + } + + fn create_pixi_prefix() -> PathBuf { + let prefix = create_test_dir("prefix").join("pixi-env"); + fs::create_dir_all(prefix.join("conda-meta")).unwrap(); + fs::write(prefix.join("conda-meta").join("pixi"), b"").unwrap(); + fs::create_dir_all(prefix.join(if cfg!(windows) { "Scripts" } else { "bin" })).unwrap(); + prefix + } + + #[test] + fn pixi_locator_reports_kind_and_supported_category() { + let locator = Pixi::default(); + + assert_eq!(locator.get_kind(), LocatorKind::Pixi); + assert_eq!( + locator.supported_categories(), + vec![PythonEnvironmentKind::Pixi] + ); + } + + #[test] + fn is_pixi_env_checks_for_pixi_marker_file() { + let prefix = create_pixi_prefix(); + + assert!(is_pixi_env(&prefix)); + assert!(!is_pixi_env(&prefix.join("conda-meta"))); + + fs::remove_dir_all(prefix.parent().unwrap()).unwrap(); + } + + #[test] + fn try_from_identifies_pixi_env_from_explicit_prefix() { + let prefix = create_pixi_prefix(); + let executable = prefix + .join(if cfg!(windows) { "Scripts" } else { "bin" }) + .join(if cfg!(windows) { + "python.exe" + } else { + "python" + }); + fs::write(&executable, b"").unwrap(); + let locator = Pixi::new(); + let env = PythonEnv::new( + executable.clone(), + Some(prefix.clone()), + Some("3.12.0".to_string()), + ); + + let pixi_env = locator.try_from(&env).unwrap(); + + assert_eq!(pixi_env.kind, Some(PythonEnvironmentKind::Pixi)); + assert_eq!(pixi_env.name, Some("pixi-env".to_string())); + assert_eq!( + pixi_env + .prefix + .as_deref() + .map(fs::canonicalize) + .transpose() + .unwrap(), + Some(fs::canonicalize(prefix.clone()).unwrap()) + ); + assert_eq!( + pixi_env + .executable + .as_deref() + .map(fs::canonicalize) + .transpose() + .unwrap(), + Some(fs::canonicalize(executable).unwrap()) + ); + + fs::remove_dir_all(prefix.parent().unwrap()).unwrap(); + } + + #[test] + fn try_from_derives_pixi_prefix_from_nested_python_executable() { + let prefix = create_pixi_prefix(); + let executable = prefix + .join(if cfg!(windows) { "Scripts" } else { "bin" }) + .join(if cfg!(windows) { + "python.exe" + } else { + "python" + }); + fs::write(&executable, b"").unwrap(); + let locator = Pixi::new(); + let env = PythonEnv::new(executable, None, None); + + let pixi_env = locator.try_from(&env).unwrap(); + + assert_eq!(pixi_env.kind, Some(PythonEnvironmentKind::Pixi)); + assert_eq!( + pixi_env + .prefix + .as_deref() + .map(fs::canonicalize) + .transpose() + .unwrap(), + Some(fs::canonicalize(prefix.clone()).unwrap()) + ); + + fs::remove_dir_all(prefix.parent().unwrap()).unwrap(); + } + + #[test] + fn try_from_rejects_non_pixi_environments() { + let prefix = create_test_dir("plain-prefix"); + let executable = prefix.join("python"); + fs::write(&executable, b"").unwrap(); + let locator = Pixi::new(); + let env = PythonEnv::new(executable, Some(prefix.clone()), None); + + assert!(locator.try_from(&env).is_none()); + + fs::remove_dir_all(prefix).unwrap(); + } +} diff --git a/crates/pet-reporter/src/cache.rs b/crates/pet-reporter/src/cache.rs index 5fb9030e..602ab361 100644 --- a/crates/pet-reporter/src/cache.rs +++ b/crates/pet-reporter/src/cache.rs @@ -66,3 +66,92 @@ impl Reporter for CacheReporter { } } } + +#[cfg(test)] +mod tests { + use super::*; + use pet_core::{ + manager::EnvManagerType, python_environment::PythonEnvironmentKind, + telemetry::TelemetryEvent, + }; + use std::{sync::Mutex, time::Duration}; + + #[derive(Default)] + struct RecordingReporter { + managers: Mutex>, + environments: Mutex>, + telemetry_count: Mutex, + } + + impl Reporter for RecordingReporter { + fn report_telemetry(&self, _event: &pet_core::telemetry::TelemetryEvent) { + *self.telemetry_count.lock().unwrap() += 1; + } + + fn report_manager(&self, manager: &EnvManager) { + self.managers.lock().unwrap().push(manager.clone()); + } + + fn report_environment(&self, env: &PythonEnvironment) { + self.environments.lock().unwrap().push(env.clone()); + } + } + + #[test] + fn cache_reporter_dedupes_managers_by_executable() { + let inner = Arc::new(RecordingReporter::default()); + let reporter = CacheReporter::new(inner.clone()); + let manager = EnvManager::new( + PathBuf::from("/tmp/conda"), + EnvManagerType::Conda, + Some("24.1.0".to_string()), + ); + + reporter.report_manager(&manager); + reporter.report_manager(&manager); + + assert_eq!(inner.managers.lock().unwrap().as_slice(), &[manager]); + } + + #[test] + fn cache_reporter_dedupes_environments_by_environment_key() { + let inner = Arc::new(RecordingReporter::default()); + let reporter = CacheReporter::new(inner.clone()); + let environment = PythonEnvironment::new( + Some(PathBuf::from("/tmp/.venv/bin/python")), + Some(PythonEnvironmentKind::Venv), + Some(PathBuf::from("/tmp/.venv")), + None, + Some("3.12.0".to_string()), + ); + + reporter.report_environment(&environment); + reporter.report_environment(&environment); + + assert_eq!( + inner.environments.lock().unwrap().as_slice(), + &[environment] + ); + } + + #[test] + fn cache_reporter_ignores_environments_without_a_key() { + let inner = Arc::new(RecordingReporter::default()); + let reporter = CacheReporter::new(inner.clone()); + let environment = PythonEnvironment::default(); + + reporter.report_environment(&environment); + + assert!(inner.environments.lock().unwrap().is_empty()); + } + + #[test] + fn cache_reporter_forwards_telemetry() { + let inner = Arc::new(RecordingReporter::default()); + let reporter = CacheReporter::new(inner.clone()); + + reporter.report_telemetry(&TelemetryEvent::SearchCompleted(Duration::from_secs(1))); + + assert_eq!(*inner.telemetry_count.lock().unwrap(), 1); + } +} diff --git a/crates/pet-reporter/src/collect.rs b/crates/pet-reporter/src/collect.rs index 0dc967af..17f76b97 100644 --- a/crates/pet-reporter/src/collect.rs +++ b/crates/pet-reporter/src/collect.rs @@ -46,3 +46,48 @@ impl Reporter for CollectReporter { pub fn create_reporter() -> CollectReporter { CollectReporter::new() } + +#[cfg(test)] +mod tests { + use super::*; + use pet_core::{ + manager::EnvManagerType, python_environment::PythonEnvironmentKind, + telemetry::TelemetryEvent, + }; + use std::{path::PathBuf, time::Duration}; + + #[test] + fn collect_reporter_accumulates_managers_and_environments() { + let reporter = create_reporter(); + let manager = EnvManager::new( + PathBuf::from("/tmp/conda"), + EnvManagerType::Conda, + Some("24.1.0".to_string()), + ); + let environment = PythonEnvironment::new( + Some(PathBuf::from("/tmp/.venv/bin/python")), + Some(PythonEnvironmentKind::Venv), + Some(PathBuf::from("/tmp/.venv")), + Some(manager.clone()), + Some("3.12.0".to_string()), + ); + + reporter.report_manager(&manager); + reporter.report_environment(&environment); + reporter.report_telemetry(&TelemetryEvent::SearchCompleted(Duration::from_secs(1))); + + assert_eq!(reporter.managers.lock().unwrap().as_slice(), &[manager]); + assert_eq!( + reporter.environments.lock().unwrap().as_slice(), + &[environment] + ); + } + + #[test] + fn default_collect_reporter_starts_empty() { + let reporter = CollectReporter::default(); + + assert!(reporter.managers.lock().unwrap().is_empty()); + assert!(reporter.environments.lock().unwrap().is_empty()); + } +} diff --git a/crates/pet-reporter/src/environment.rs b/crates/pet-reporter/src/environment.rs index 2bae3d74..b2a622ad 100644 --- a/crates/pet-reporter/src/environment.rs +++ b/crates/pet-reporter/src/environment.rs @@ -11,11 +11,14 @@ pub fn get_environment_key(env: &PythonEnvironment) -> Option { } else if let Some(prefix) = &env.prefix { // If this is a conda env without Python, then the exe will be prefix/bin/python if env.kind == Some(PythonEnvironmentKind::Conda) { - Some(prefix.join("bin").join(if cfg!(windows) { - "python.exe" - } else { - "python" - })) + #[cfg(windows)] + { + Some(prefix.join("python.exe")) + } + #[cfg(not(windows))] + { + Some(prefix.join("bin").join("python")) + } } else { Some(prefix.clone()) } @@ -27,3 +30,62 @@ pub fn get_environment_key(env: &PythonEnvironment) -> Option { None } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn executable_is_used_as_environment_key() { + let executable = PathBuf::from("/tmp/.venv/bin/python"); + let environment = PythonEnvironment::new( + Some(executable.clone()), + Some(PythonEnvironmentKind::Venv), + Some(PathBuf::from("/tmp/.venv")), + None, + None, + ); + + assert_eq!(get_environment_key(&environment), Some(executable)); + } + + #[test] + fn conda_prefix_without_executable_gets_default_python_path() { + let prefix = PathBuf::from("/tmp/conda-env"); + let environment = PythonEnvironment::new( + None, + Some(PythonEnvironmentKind::Conda), + Some(prefix.clone()), + None, + None, + ); + + assert_eq!( + get_environment_key(&environment), + Some(if cfg!(windows) { + prefix.join("python.exe") + } else { + prefix.join("bin").join("python") + }) + ); + } + + #[test] + fn non_conda_prefix_without_executable_uses_prefix() { + let prefix = PathBuf::from("/tmp/.venv"); + let environment = PythonEnvironment::new( + None, + Some(PythonEnvironmentKind::Venv), + Some(prefix.clone()), + None, + None, + ); + + assert_eq!(get_environment_key(&environment), Some(prefix)); + } + + #[test] + fn environment_without_executable_or_prefix_has_no_key() { + assert_eq!(get_environment_key(&PythonEnvironment::default()), None); + } +} diff --git a/crates/pet-reporter/src/stdio.rs b/crates/pet-reporter/src/stdio.rs index 3166f7a3..37ba376d 100644 --- a/crates/pet-reporter/src/stdio.rs +++ b/crates/pet-reporter/src/stdio.rs @@ -116,3 +116,73 @@ pub struct Log { pub fn initialize_logger(log_level: LevelFilter) { Builder::new().filter(None, log_level).init(); } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn create_environment(kind: PythonEnvironmentKind, executable: &str) -> PythonEnvironment { + PythonEnvironment::new( + Some(PathBuf::from(executable)), + Some(kind), + Some(PathBuf::from("/tmp/env")), + None, + Some("3.12.0".to_string()), + ) + } + + #[test] + fn stdio_reporter_counts_managers_and_environments() { + let reporter = create_reporter(false, None); + let manager = EnvManager::new( + PathBuf::from("/tmp/conda"), + EnvManagerType::Conda, + Some("24.1.0".to_string()), + ); + let environment = create_environment(PythonEnvironmentKind::Venv, "/tmp/.venv/bin/python"); + + reporter.report_manager(&manager); + reporter.report_manager(&manager); + reporter.report_environment(&environment); + reporter.report_environment(&environment); + + let summary = reporter.get_summary(); + assert_eq!(summary.managers.get(&EnvManagerType::Conda), Some(&2)); + assert_eq!( + summary.environments.get(&Some(PythonEnvironmentKind::Venv)), + Some(&2) + ); + assert_eq!( + summary + .environment_paths + .get(&Some(PythonEnvironmentKind::Venv)) + .unwrap() + .as_slice(), + &[environment.clone(), environment] + ); + } + + #[test] + fn stdio_reporter_filters_environments_by_requested_kind() { + let reporter = create_reporter(false, Some(PythonEnvironmentKind::Poetry)); + let poetry_environment = + create_environment(PythonEnvironmentKind::Poetry, "/tmp/poetry/bin/python"); + let venv_environment = + create_environment(PythonEnvironmentKind::Venv, "/tmp/.venv/bin/python"); + + reporter.report_environment(&venv_environment); + reporter.report_environment(&poetry_environment); + + let summary = reporter.get_summary(); + assert!(!summary + .environments + .contains_key(&Some(PythonEnvironmentKind::Venv))); + assert_eq!( + summary + .environments + .get(&Some(PythonEnvironmentKind::Poetry)), + Some(&1) + ); + } +} diff --git a/crates/pet-virtualenvwrapper/src/env_variables.rs b/crates/pet-virtualenvwrapper/src/env_variables.rs index 1a8f2e28..7971fa97 100644 --- a/crates/pet-virtualenvwrapper/src/env_variables.rs +++ b/crates/pet-virtualenvwrapper/src/env_variables.rs @@ -20,3 +20,65 @@ impl EnvVariables { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + struct TestEnvironment { + user_home: Option, + workon_home: Option, + } + + impl Environment for TestEnvironment { + fn get_user_home(&self) -> Option { + self.user_home.clone() + } + + fn get_root(&self) -> Option { + None + } + + fn get_env_var(&self, key: String) -> Option { + if key == "WORKON_HOME" { + self.workon_home.clone() + } else { + None + } + } + + fn get_know_global_search_locations(&self) -> Vec { + vec![] + } + } + + #[test] + fn env_variables_reads_home_and_workon_home() { + let environment = TestEnvironment { + user_home: Some(PathBuf::from("/home/user")), + workon_home: Some("/tmp/workon-home".to_string()), + }; + + let env_variables = EnvVariables::from(&environment); + + assert_eq!(env_variables.home, Some(PathBuf::from("/home/user"))); + assert_eq!( + env_variables.workon_home, + Some("/tmp/workon-home".to_string()) + ); + } + + #[test] + fn env_variables_preserves_missing_values() { + let environment = TestEnvironment { + user_home: None, + workon_home: None, + }; + + let env_variables = EnvVariables::from(&environment); + + assert_eq!(env_variables.home, None); + assert_eq!(env_variables.workon_home, None); + } +} diff --git a/crates/pet-virtualenvwrapper/src/environment_locations.rs b/crates/pet-virtualenvwrapper/src/environment_locations.rs index 72c2e6c2..4ffca8d0 100644 --- a/crates/pet-virtualenvwrapper/src/environment_locations.rs +++ b/crates/pet-virtualenvwrapper/src/environment_locations.rs @@ -16,6 +16,10 @@ fn get_default_virtualenvwrapper_path(env_vars: &EnvVariables) -> Option Option { // The WORKON_HOME variable contains the path to the root directory of all virtualenvwrapper environments. // If the interpreter path belongs to one of them then it is a virtualenvwrapper type of environment. if let Some(work_on_home) = &environment.workon_home { - // TODO: Why do we need to canonicalize the path? - if let Ok(work_on_home) = std::fs::canonicalize(work_on_home) { - if work_on_home.exists() { - return Some(norm_case(&work_on_home)); - } + let work_on_home = norm_case(PathBuf::from(work_on_home)); + if work_on_home.exists() { + return Some(work_on_home); } } get_default_virtualenvwrapper_path(environment) } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn create_test_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "pet-virtualenvwrapper-{name}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + directory + } + + #[test] + fn workon_home_path_prefers_existing_workon_home_env_var() { + let workon_home = create_test_dir("workon-home"); + let env_variables = EnvVariables { + home: None, + workon_home: Some(workon_home.to_string_lossy().to_string()), + }; + + assert_eq!( + get_work_on_home_path(&env_variables), + Some(norm_case(&workon_home)) + ); + + fs::remove_dir_all(workon_home).unwrap(); + } + + #[test] + fn workon_home_path_falls_back_to_default_home_location() { + let user_home = create_test_dir("home"); + + #[cfg(windows)] + let default_home = user_home.join("Envs"); + #[cfg(unix)] + let default_home = user_home.join(".virtualenvs"); + + fs::create_dir_all(&default_home).unwrap(); + let env_variables = EnvVariables { + home: Some(user_home.clone()), + workon_home: None, + }; + + assert_eq!( + get_work_on_home_path(&env_variables), + Some(norm_case(default_home)) + ); + + fs::remove_dir_all(user_home).unwrap(); + } + + #[cfg(windows)] + #[test] + fn workon_home_path_falls_back_to_dot_virtualenvs_on_windows() { + let user_home = create_test_dir("windows-home"); + let default_home = user_home.join(".virtualenvs"); + fs::create_dir_all(&default_home).unwrap(); + let env_variables = EnvVariables { + home: Some(user_home.clone()), + workon_home: None, + }; + + assert_eq!( + get_work_on_home_path(&env_variables), + Some(norm_case(default_home)) + ); + + fs::remove_dir_all(user_home).unwrap(); + } + + #[cfg(windows)] + #[test] + fn workon_home_path_supports_legacy_virtualenvs_without_dot_on_windows() { + let user_home = create_test_dir("windows-home-legacy"); + let default_home = user_home.join("virtualenvs"); + fs::create_dir_all(&default_home).unwrap(); + let env_variables = EnvVariables { + home: Some(user_home.clone()), + workon_home: None, + }; + + assert_eq!( + get_work_on_home_path(&env_variables), + Some(norm_case(default_home)) + ); + + fs::remove_dir_all(user_home).unwrap(); + } + + #[test] + fn workon_home_path_returns_none_when_no_candidate_exists() { + let workon_home = create_test_dir("missing-workon-home"); + let env_variables = EnvVariables { + home: None, + workon_home: Some(workon_home.join("missing").to_string_lossy().to_string()), + }; + + assert_eq!(get_work_on_home_path(&env_variables), None); + + fs::remove_dir_all(workon_home).unwrap(); + } +} diff --git a/crates/pet-virtualenvwrapper/src/environments.rs b/crates/pet-virtualenvwrapper/src/environments.rs index d7f02aa7..3a4ef582 100644 --- a/crates/pet-virtualenvwrapper/src/environments.rs +++ b/crates/pet-virtualenvwrapper/src/environments.rs @@ -5,7 +5,25 @@ use crate::{env_variables::EnvVariables, environment_locations::get_work_on_home use pet_core::env::PythonEnv; use pet_fs::path::norm_case; use pet_virtualenv::is_virtualenv; -use std::{fs, path::PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +fn is_under_work_on_home(executable: &Path, work_on_home_dir: &Path) -> bool { + if executable.starts_with(work_on_home_dir) { + return true; + } + + if let (Ok(executable), Ok(work_on_home_dir)) = ( + fs::canonicalize(executable), + fs::canonicalize(work_on_home_dir), + ) { + return norm_case(executable).starts_with(norm_case(work_on_home_dir)); + } + + false +} pub fn is_virtualenvwrapper(env: &PythonEnv, environment: &EnvVariables) -> bool { if env.prefix.is_none() { @@ -16,7 +34,7 @@ pub fn is_virtualenvwrapper(env: &PythonEnv, environment: &EnvVariables) -> bool // 1. It should be in a sub-directory under the WORKON_HOME // 2. It should be a valid virtualenv environment if let Some(work_on_home_dir) = get_work_on_home_path(environment) { - if env.executable.starts_with(work_on_home_dir) && is_virtualenv(env) { + if is_under_work_on_home(&env.executable, &work_on_home_dir) && is_virtualenv(env) { return true; } } @@ -35,6 +53,155 @@ pub fn get_project(env: &PythonEnv) -> Option { } } +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + path::Path, + time::{SystemTime, UNIX_EPOCH}, + }; + + #[cfg(windows)] + use std::os::windows::fs::symlink_dir; + + fn create_test_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "pet-virtualenvwrapper-{name}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + directory + } + + fn create_virtualenv(prefix: &Path) -> PathBuf { + let scripts_dir = prefix.join(if cfg!(windows) { "Scripts" } else { "bin" }); + fs::create_dir_all(&scripts_dir).unwrap(); + fs::write( + scripts_dir.join(if cfg!(windows) { + "activate.bat" + } else { + "activate" + }), + b"", + ) + .unwrap(); + let executable = scripts_dir.join(if cfg!(windows) { + "python.exe" + } else { + "python" + }); + fs::write(&executable, b"").unwrap(); + executable + } + + #[test] + fn is_virtualenvwrapper_requires_prefix_inside_workon_home_and_valid_virtualenv() { + let workon_home = create_test_dir("workon-home"); + let prefix = workon_home.join("wrapped-env"); + let executable = create_virtualenv(&prefix); + let env = PythonEnv::new(executable, Some(prefix.clone()), None); + let env_variables = EnvVariables { + home: None, + workon_home: Some(workon_home.to_string_lossy().to_string()), + }; + + assert!(is_virtualenvwrapper(&env, &env_variables)); + + fs::remove_dir_all(workon_home).unwrap(); + } + + #[test] + fn is_virtualenvwrapper_rejects_env_without_prefix_or_outside_workon_home() { + let workon_home = create_test_dir("workon-home"); + let outside_prefix = create_test_dir("outside-env").join("wrapped-env"); + let executable = create_virtualenv(&outside_prefix); + let env_variables = EnvVariables { + home: None, + workon_home: Some(workon_home.to_string_lossy().to_string()), + }; + + let no_prefix_env = PythonEnv::new(executable.clone(), None, None); + let outside_env = PythonEnv::new(executable, Some(outside_prefix.clone()), None); + + assert!(!is_virtualenvwrapper(&no_prefix_env, &env_variables)); + assert!(!is_virtualenvwrapper(&outside_env, &env_variables)); + + fs::remove_dir_all(workon_home).unwrap(); + fs::remove_dir_all(outside_prefix.parent().unwrap()).unwrap(); + } + + #[cfg(windows)] + #[test] + fn is_virtualenvwrapper_accepts_env_under_symlinked_workon_home() { + let real_workon_home = create_test_dir("real-workon-home"); + let linked_parent = create_test_dir("linked-parent"); + let linked_workon_home = linked_parent.join("linked-workon-home"); + if let Err(error) = symlink_dir(&real_workon_home, &linked_workon_home) { + eprintln!( + "Skipping symlinked WORKON_HOME test because symlink creation failed: {error:?}" + ); + fs::remove_dir_all(real_workon_home).unwrap(); + fs::remove_dir_all(linked_parent).unwrap(); + return; + } + + let prefix = real_workon_home.join("wrapped-env"); + let executable = create_virtualenv(&prefix); + let env = PythonEnv::new(executable, Some(prefix), None); + let env_variables = EnvVariables { + home: None, + workon_home: Some(linked_workon_home.to_string_lossy().to_string()), + }; + + assert!(is_virtualenvwrapper(&env, &env_variables)); + + fs::remove_dir_all(linked_workon_home).unwrap(); + fs::remove_dir_all(linked_parent).unwrap(); + fs::remove_dir_all(real_workon_home).unwrap(); + } + + #[test] + fn get_project_reads_existing_project_path_from_project_file() { + let project_root = create_test_dir("project-root"); + let prefix = create_test_dir("wrapped-env"); + let executable = create_virtualenv(&prefix); + fs::write( + prefix.join(".project"), + format!(" {} \n", project_root.display()), + ) + .unwrap(); + let env = PythonEnv::new(executable, Some(prefix.clone()), None); + + assert_eq!(get_project(&env), Some(norm_case(project_root.clone()))); + + fs::remove_dir_all(project_root).unwrap(); + fs::remove_dir_all(prefix).unwrap(); + } + + #[test] + fn get_project_returns_none_for_missing_prefix_or_missing_project_path() { + let prefix = create_test_dir("wrapped-env"); + let executable = create_virtualenv(&prefix); + fs::write( + prefix.join(".project"), + prefix.join("missing").display().to_string(), + ) + .unwrap(); + let missing_project_env = PythonEnv::new(executable.clone(), Some(prefix.clone()), None); + let no_prefix_env = PythonEnv::new(executable, None, None); + + assert_eq!(get_project(&missing_project_env), None); + assert_eq!(get_project(&no_prefix_env), None); + + fs::remove_dir_all(prefix).unwrap(); + } +} + // pub fn list_python_environments(path: &PathBuf) -> Option> { // let mut python_envs: Vec = vec![]; // for venv_dir in fs::read_dir(path) diff --git a/crates/pet-virtualenvwrapper/src/lib.rs b/crates/pet-virtualenvwrapper/src/lib.rs index a76f8262..b1414c88 100644 --- a/crates/pet-virtualenvwrapper/src/lib.rs +++ b/crates/pet-virtualenvwrapper/src/lib.rs @@ -69,3 +69,187 @@ impl Locator for VirtualEnvWrapper { fn find(&self, _reporter: &dyn Reporter) {} } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; + + struct TestEnvironment { + user_home: Option, + env_vars: HashMap, + } + + impl Environment for TestEnvironment { + fn get_user_home(&self) -> Option { + self.user_home.clone() + } + + fn get_root(&self) -> Option { + None + } + + fn get_env_var(&self, key: String) -> Option { + self.env_vars.get(&key).cloned() + } + + fn get_know_global_search_locations(&self) -> Vec { + vec![] + } + } + + fn create_test_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "pet-virtualenvwrapper-{name}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + directory + } + + fn create_virtualenv(prefix: &Path) -> PathBuf { + let scripts_dir = prefix.join(if cfg!(windows) { "Scripts" } else { "bin" }); + fs::create_dir_all(&scripts_dir).unwrap(); + fs::write( + scripts_dir.join(if cfg!(windows) { + "activate.bat" + } else { + "activate" + }), + b"", + ) + .unwrap(); + let executable = scripts_dir.join(if cfg!(windows) { + "python.exe" + } else { + "python" + }); + fs::write(&executable, b"").unwrap(); + executable + } + + #[test] + fn virtualenvwrapper_reports_kind_and_supported_category() { + let locator = VirtualEnvWrapper { + env_vars: EnvVariables { + home: None, + workon_home: None, + }, + }; + + assert_eq!(locator.get_kind(), LocatorKind::VirtualEnvWrapper); + assert_eq!( + locator.supported_categories(), + vec![PythonEnvironmentKind::VirtualEnvWrapper] + ); + } + + #[test] + fn from_reads_environment_variables() { + let workon_home = create_test_dir("workon-home"); + let mut env_vars = HashMap::new(); + env_vars.insert( + "WORKON_HOME".to_string(), + workon_home.to_string_lossy().to_string(), + ); + let environment = TestEnvironment { + user_home: Some(workon_home.clone()), + env_vars, + }; + + let locator = VirtualEnvWrapper::from(&environment); + + assert_eq!(locator.env_vars.home, Some(workon_home.clone())); + assert_eq!( + locator.env_vars.workon_home, + Some(workon_home.to_string_lossy().to_string()) + ); + + fs::remove_dir_all(workon_home).unwrap(); + } + + #[test] + fn try_from_builds_virtualenvwrapper_environment() { + let workon_home = create_test_dir("workon-home"); + let prefix = workon_home.join("wrapped-env"); + let executable = create_virtualenv(&prefix); + let project_root = create_test_dir("project-root"); + fs::write(prefix.join(".project"), project_root.display().to_string()).unwrap(); + let locator = VirtualEnvWrapper { + env_vars: EnvVariables { + home: None, + workon_home: Some(workon_home.to_string_lossy().to_string()), + }, + }; + let env = PythonEnv::new( + executable.clone(), + Some(prefix.clone()), + Some("3.12.1".to_string()), + ); + + let virtualenvwrapper_env = locator.try_from(&env).unwrap(); + + assert_eq!( + virtualenvwrapper_env.kind, + Some(PythonEnvironmentKind::VirtualEnvWrapper) + ); + assert_eq!(virtualenvwrapper_env.name, Some("wrapped-env".to_string())); + assert_eq!( + virtualenvwrapper_env + .executable + .as_ref() + .map(pet_fs::path::norm_case), + Some(pet_fs::path::norm_case(executable)) + ); + assert_eq!(virtualenvwrapper_env.version, Some("3.12.1".to_string())); + assert_eq!( + virtualenvwrapper_env.prefix, + Some(pet_fs::path::norm_case(prefix.clone())) + ); + assert_eq!( + virtualenvwrapper_env.project, + Some(pet_fs::path::norm_case(project_root.clone())) + ); + assert!(virtualenvwrapper_env + .symlinks + .iter() + .flatten() + .any(|symlink| symlink.file_name() + == virtualenvwrapper_env + .executable + .as_ref() + .unwrap() + .file_name())); + + fs::remove_dir_all(workon_home).unwrap(); + fs::remove_dir_all(project_root).unwrap(); + } + + #[test] + fn try_from_returns_none_for_non_virtualenvwrapper_env() { + let prefix = create_test_dir("standalone-env"); + let workon_home = create_test_dir("workon-home"); + let executable = create_virtualenv(&prefix); + let locator = VirtualEnvWrapper { + env_vars: EnvVariables { + home: None, + workon_home: Some(workon_home.to_string_lossy().to_string()), + }, + }; + let env = PythonEnv::new(executable, Some(prefix.clone()), None); + + assert!(locator.try_from(&env).is_none()); + + fs::remove_dir_all(prefix).unwrap(); + fs::remove_dir_all(workon_home).unwrap(); + } +} diff --git a/crates/pet-windows-registry/src/lib.rs b/crates/pet-windows-registry/src/lib.rs index aab44348..ae550c45 100644 --- a/crates/pet-windows-registry/src/lib.rs +++ b/crates/pet-windows-registry/src/lib.rs @@ -146,12 +146,73 @@ mod tests { use super::*; use pet_conda::Conda; use pet_core::os_environment::EnvironmentApi; + use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn create_test_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "pet-windows-registry-{name}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&directory).unwrap(); + directory + } + + fn create_virtualenv(prefix: &Path) -> PathBuf { + let scripts_dir = prefix.join(if cfg!(windows) { "Scripts" } else { "bin" }); + fs::create_dir_all(&scripts_dir).unwrap(); + fs::write( + scripts_dir.join(if cfg!(windows) { + "activate.bat" + } else { + "activate" + }), + b"", + ) + .unwrap(); + let executable = scripts_dir.join(if cfg!(windows) { + "python.exe" + } else { + "python" + }); + fs::write(&executable, b"").unwrap(); + executable + } + + fn create_locator() -> WindowsRegistry { + let environment = EnvironmentApi::new(); + WindowsRegistry::from(Arc::new(Conda::from(&environment))) + } + + #[test] + fn test_windows_registry_reports_kind_categories_and_refresh_state() { + let locator = create_locator(); + + assert_eq!(locator.get_kind(), LocatorKind::WindowsRegistry); + assert_eq!( + locator.supported_categories(), + vec![ + PythonEnvironmentKind::WindowsRegistry, + PythonEnvironmentKind::Conda + ] + ); + assert_eq!( + locator.refresh_state(), + RefreshStatePersistence::SyncedDiscoveryState + ); + } #[test] fn test_full_refresh_sync_replaces_registry_cache() { - let environment = EnvironmentApi::new(); - let shared = WindowsRegistry::from(Arc::new(Conda::from(&environment))); - let refreshed = WindowsRegistry::from(Arc::new(Conda::from(&environment))); + let shared = create_locator(); + let refreshed = create_locator(); shared.search_result.lock().unwrap().replace(LocatorResult { managers: vec![], @@ -180,9 +241,8 @@ mod tests { #[test] fn test_workspace_scope_does_not_replace_registry_cache() { - let environment = EnvironmentApi::new(); - let shared = WindowsRegistry::from(Arc::new(Conda::from(&environment))); - let refreshed = WindowsRegistry::from(Arc::new(Conda::from(&environment))); + let shared = create_locator(); + let refreshed = create_locator(); shared.search_result.lock().unwrap().replace(LocatorResult { managers: vec![], @@ -208,4 +268,77 @@ mod tests { let result = shared.search_result.lock().unwrap().clone().unwrap(); assert_eq!(result.environments[0].name.as_deref(), Some("stale")); } + + #[test] + fn test_global_filtered_scope_syncs_supported_kinds_only() { + let shared = create_locator(); + let refreshed = create_locator(); + + shared.search_result.lock().unwrap().replace(LocatorResult { + managers: vec![], + environments: vec![PythonEnvironment { + name: Some("stale".to_string()), + ..Default::default() + }], + }); + refreshed + .search_result + .lock() + .unwrap() + .replace(LocatorResult { + managers: vec![], + environments: vec![PythonEnvironment { + name: Some("fresh".to_string()), + ..Default::default() + }], + }); + + shared.sync_refresh_state_from( + &refreshed, + &RefreshStateSyncScope::GlobalFiltered(PythonEnvironmentKind::WindowsRegistry), + ); + let result = shared.search_result.lock().unwrap().clone().unwrap(); + assert_eq!(result.environments[0].name.as_deref(), Some("fresh")); + + shared.search_result.lock().unwrap().replace(LocatorResult { + managers: vec![], + environments: vec![PythonEnvironment { + name: Some("stale".to_string()), + ..Default::default() + }], + }); + + shared.sync_refresh_state_from( + &refreshed, + &RefreshStateSyncScope::GlobalFiltered(PythonEnvironmentKind::Venv), + ); + let result = shared.search_result.lock().unwrap().clone().unwrap(); + assert_eq!(result.environments[0].name.as_deref(), Some("stale")); + } + + #[test] + fn test_try_from_rejects_virtualenv_before_registry_lookup() { + let prefix = create_test_dir("virtualenv"); + let executable = create_virtualenv(&prefix); + let env = PythonEnv::new(executable, Some(prefix.clone()), None); + let locator = create_locator(); + + assert!(locator.try_from(&env).is_none()); + + fs::remove_dir_all(prefix).unwrap(); + } + + #[test] + fn test_try_from_rejects_conda_prefix_before_registry_lookup() { + let prefix = create_test_dir("conda-env"); + fs::create_dir_all(prefix.join("conda-meta")).unwrap(); + let executable = prefix.join("python.exe"); + fs::write(&executable, b"").unwrap(); + let env = PythonEnv::new(executable, Some(prefix.clone()), None); + let locator = create_locator(); + + assert!(locator.try_from(&env).is_none()); + + fs::remove_dir_all(prefix).unwrap(); + } } diff --git a/crates/pet-windows-store/src/env_variables.rs b/crates/pet-windows-store/src/env_variables.rs index ab4af6bb..e4b01173 100644 --- a/crates/pet-windows-store/src/env_variables.rs +++ b/crates/pet-windows-store/src/env_variables.rs @@ -18,3 +18,49 @@ impl EnvVariables { } } } + +#[cfg(test)] +mod tests { + use super::*; + + struct TestEnvironment { + user_home: Option, + } + + impl Environment for TestEnvironment { + fn get_user_home(&self) -> Option { + self.user_home.clone() + } + + fn get_root(&self) -> Option { + None + } + + fn get_env_var(&self, _key: String) -> Option { + None + } + + fn get_know_global_search_locations(&self) -> Vec { + vec![] + } + } + + #[test] + fn env_variables_reads_home() { + let environment = TestEnvironment { + user_home: Some(PathBuf::from(r"C:\\Users\\User")), + }; + + assert_eq!( + EnvVariables::from(&environment).home, + Some(PathBuf::from(r"C:\\Users\\User")) + ); + } + + #[test] + fn env_variables_preserves_missing_home() { + let environment = TestEnvironment { user_home: None }; + + assert_eq!(EnvVariables::from(&environment).home, None); + } +} diff --git a/crates/pet-windows-store/src/environment_locations.rs b/crates/pet-windows-store/src/environment_locations.rs index 2aa591ea..665be23b 100644 --- a/crates/pet-windows-store/src/environment_locations.rs +++ b/crates/pet-windows-store/src/environment_locations.rs @@ -18,3 +18,31 @@ pub fn get_search_locations(environment: &EnvVariables) -> Option { .join("WindowsApps"), ) } + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn search_locations_use_windowsapps_under_user_home() { + let home = PathBuf::from(r"C:\\Users\\User"); + let env_variables = EnvVariables { + home: Some(home.clone()), + }; + + assert_eq!( + get_search_locations(&env_variables), + Some( + home.join("AppData") + .join("Local") + .join("Microsoft") + .join("WindowsApps") + ) + ); + } + + #[test] + fn search_locations_return_none_without_home() { + assert_eq!(get_search_locations(&EnvVariables { home: None }), None); + } +} diff --git a/crates/pet-windows-store/src/lib.rs b/crates/pet-windows-store/src/lib.rs index e37ed401..5f098682 100644 --- a/crates/pet-windows-store/src/lib.rs +++ b/crates/pet-windows-store/src/lib.rs @@ -19,8 +19,9 @@ use std::path::Path; use std::sync::{Arc, RwLock}; pub fn is_windows_app_folder_in_program_files(path: &Path) -> bool { - path.to_str().unwrap_or_default().to_string().to_lowercase()[1..] - .starts_with(":\\program files\\windowsapps") + let path = path.to_str().unwrap_or_default().to_ascii_lowercase(); + path.get(1..) + .is_some_and(|path| path.starts_with(":\\program files\\windowsapps")) } pub struct WindowsStore { @@ -178,6 +179,33 @@ mod tests { use super::*; use pet_core::os_environment::EnvironmentApi; + #[test] + fn windows_store_reports_kind_supported_categories_and_refresh_state() { + let environment = EnvironmentApi::new(); + let locator = WindowsStore::from(&environment); + + assert_eq!(locator.get_kind(), LocatorKind::WindowsStore); + assert_eq!( + locator.supported_categories(), + vec![PythonEnvironmentKind::WindowsStore] + ); + assert_eq!( + locator.refresh_state(), + RefreshStatePersistence::SyncedDiscoveryState + ); + } + + #[test] + fn is_windows_app_folder_in_program_files_handles_windowsapps_paths_and_short_inputs() { + assert!(is_windows_app_folder_in_program_files(Path::new( + r"C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.12_qbz5n2kfra8p0" + ))); + assert!(!is_windows_app_folder_in_program_files(Path::new( + r"C:\Users\User\AppData\Local\Microsoft\WindowsApps" + ))); + assert!(!is_windows_app_folder_in_program_files(Path::new(""))); + } + #[test] fn test_full_refresh_sync_replaces_store_cache() { let environment = EnvironmentApi::new(); @@ -235,4 +263,50 @@ mod tests { let result = shared.environments.read().unwrap().clone().unwrap(); assert_eq!(result[0].name.as_deref(), Some("stale")); } + + #[test] + fn test_global_filtered_scope_syncs_only_for_windows_store_kind() { + let environment = EnvironmentApi::new(); + let shared = WindowsStore::from(&environment); + let refreshed = WindowsStore::from(&environment); + + shared + .environments + .write() + .unwrap() + .replace(vec![PythonEnvironment { + name: Some("stale".to_string()), + ..Default::default() + }]); + refreshed + .environments + .write() + .unwrap() + .replace(vec![PythonEnvironment { + name: Some("fresh".to_string()), + ..Default::default() + }]); + + shared.sync_refresh_state_from( + &refreshed, + &RefreshStateSyncScope::GlobalFiltered(PythonEnvironmentKind::WindowsStore), + ); + let result = shared.environments.read().unwrap().clone().unwrap(); + assert_eq!(result[0].name.as_deref(), Some("fresh")); + + shared + .environments + .write() + .unwrap() + .replace(vec![PythonEnvironment { + name: Some("stale".to_string()), + ..Default::default() + }]); + shared.sync_refresh_state_from( + &refreshed, + &RefreshStateSyncScope::GlobalFiltered(PythonEnvironmentKind::Conda), + ); + let result = shared.environments.read().unwrap().clone().unwrap(); + assert_eq!(result[0].name.as_deref(), Some("stale")); + } }