Skip to content

Commit d611162

Browse files
committed
refactor: extract build_refresh_config into testable helper
Addresses PR review comments: 1. Extract the refresh-option→config/search_scope logic into a helper function �uild_refresh_config that can be unit tested 2. Add fast, deterministic unit tests that directly test the bug fix: - test_search_kind_preserves_workspace_directories: verifies the critical fix that workspace_directories are NOT cleared when only searchKind is provided - test_search_paths_replaces_workspace_directories: verifies searchPaths still correctly replaces workspace_directories - test_no_options_preserves_config: verifies default behavior 3. Remove the slow integration-style test from find.rs that was environment-dependent and didn't actually test the bug in handle_refresh Fixes #151
1 parent 2ac8f88 commit d611162

File tree

2 files changed

+163
-148
lines changed

2 files changed

+163
-148
lines changed

crates/pet/src/find.rs

Lines changed: 0 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -653,99 +653,4 @@ mod tests {
653653
"canonicalize() would resolve to target, but path() does not"
654654
);
655655
}
656-
657-
/// Test for https://github.com/microsoft/python-environment-tools/issues/151
658-
/// Verifies that refresh with searchKind (e.g., "Venv") still finds environments
659-
/// in workspace directories, not just global locations.
660-
///
661-
/// The bug was that when searchKind was provided, workspace_directories was cleared,
662-
/// preventing discovery of workspace-based environments like venvs.
663-
#[test]
664-
fn test_search_kind_finds_workspace_venvs() {
665-
use super::{find_and_report_envs, SearchScope};
666-
use crate::locators::create_locators;
667-
use pet_conda::Conda;
668-
use pet_core::os_environment::EnvironmentApi;
669-
use pet_core::python_environment::PythonEnvironmentKind;
670-
use pet_core::Configuration;
671-
use pet_poetry::Poetry;
672-
use pet_reporter::collect;
673-
use std::sync::Arc;
674-
675-
let tmp = TempDir::new().expect("Failed to create temp dir");
676-
let workspace = tmp.path().to_path_buf();
677-
678-
// Create a venv structure in the workspace
679-
let venv_dir = workspace.join(".venv");
680-
#[cfg(windows)]
681-
let bin_dir = venv_dir.join("Scripts");
682-
#[cfg(unix)]
683-
let bin_dir = venv_dir.join("bin");
684-
fs::create_dir_all(&bin_dir).expect("Failed to create bin dir");
685-
686-
// Create pyvenv.cfg (required for venv detection)
687-
fs::write(
688-
venv_dir.join("pyvenv.cfg"),
689-
"home = /usr/bin\nversion = 3.11.0\n",
690-
)
691-
.expect("Failed to create pyvenv.cfg");
692-
693-
// Create python executable
694-
#[cfg(windows)]
695-
let python_exe = bin_dir.join("python.exe");
696-
#[cfg(unix)]
697-
let python_exe = bin_dir.join("python");
698-
fs::write(&python_exe, "fake python").expect("Failed to create python exe");
699-
700-
// Set up locators and configuration
701-
let environment = EnvironmentApi::new();
702-
let conda_locator = Arc::new(Conda::from(&environment));
703-
let poetry_locator = Arc::new(Poetry::from(&environment));
704-
let locators = create_locators(conda_locator, poetry_locator, &environment);
705-
706-
let config = Configuration {
707-
workspace_directories: Some(vec![workspace.clone()]),
708-
..Default::default()
709-
};
710-
for locator in locators.iter() {
711-
locator.configure(&config);
712-
}
713-
714-
let reporter = Arc::new(collect::create_reporter());
715-
716-
// Call find_and_report_envs with SearchScope::Global(Venv)
717-
// This simulates the behavior when refresh is called with searchKind: "Venv"
718-
find_and_report_envs(
719-
reporter.as_ref(),
720-
config,
721-
&locators,
722-
&environment,
723-
Some(SearchScope::Global(PythonEnvironmentKind::Venv)),
724-
);
725-
726-
let environments = reporter.environments.lock().unwrap();
727-
728-
// Canonicalize the venv_dir for comparison (handles Windows 8.3 short paths)
729-
let venv_dir_canonical = fs::canonicalize(&venv_dir).unwrap_or(venv_dir.clone());
730-
731-
// The venv should be discovered even when searching by kind
732-
// Use canonicalize to handle Windows short path names (e.g., RUNNER~1 vs runneradmin)
733-
let venv_found = environments.iter().any(|env| {
734-
env.kind == Some(PythonEnvironmentKind::Venv)
735-
&& env
736-
.prefix
737-
.as_ref()
738-
.map(|p| {
739-
let p_canonical = fs::canonicalize(p).unwrap_or(p.clone());
740-
p_canonical == venv_dir_canonical
741-
})
742-
.unwrap_or(false)
743-
});
744-
745-
assert!(
746-
venv_found,
747-
"Venv in workspace should be found when searching by kind. Found environments: {:?}",
748-
*environments
749-
);
750-
}
751656
}

crates/pet/src/jsonrpc.rs

Lines changed: 163 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -204,66 +204,27 @@ pub fn handle_refresh(context: Arc<Context>, id: u32, params: Value) {
204204
// Ensure we can have only one refresh at a time.
205205
let lock = REFRESH_LOCK.lock().expect("REFRESH_LOCK mutex poisoned");
206206

207-
let mut config = context.configuration.read().unwrap().clone();
207+
let config = context.configuration.read().unwrap().clone();
208208
let reporter = Arc::new(CacheReporter::new(Arc::new(jsonrpc::create_reporter(
209209
refresh_options.search_kind,
210210
))));
211211

212-
let mut search_scope = None;
213-
214-
// If search_paths is provided, limit search to those paths.
215-
// If only search_kind is provided (without search_paths), we still search
216-
// workspace directories because many environment types (like Venv, VirtualEnv)
217-
// don't have global locations - they only exist in workspace folders.
218-
// The reporter will filter results to only report the requested kind.
219-
if let Some(search_paths) = refresh_options.search_paths {
220-
// Clear workspace directories when explicit search paths are provided.
221-
config.workspace_directories = None;
222-
// Expand any glob patterns in the search paths
223-
let expanded_paths = expand_glob_patterns(&search_paths);
212+
let (config, search_scope) = build_refresh_config(&refresh_options, config);
213+
if refresh_options.search_paths.is_some() {
224214
trace!(
225-
"Expanded {} search paths to {} paths",
226-
search_paths.len(),
227-
expanded_paths.len()
228-
);
229-
// These workspace folders are only for this refresh.
230-
config.workspace_directories = Some(
231-
expanded_paths
232-
.iter()
233-
.filter(|p| p.is_dir())
234-
.cloned()
235-
.collect(),
215+
"Expanded search paths to {} workspace dirs, {} executables",
216+
config
217+
.workspace_directories
218+
.as_ref()
219+
.map(|v| v.len())
220+
.unwrap_or(0),
221+
config.executables.as_ref().map(|v| v.len()).unwrap_or(0)
236222
);
237-
config.executables = Some(
238-
expanded_paths
239-
.iter()
240-
.filter(|p| p.is_file())
241-
.cloned()
242-
.collect(),
243-
);
244-
search_scope = Some(SearchScope::Workspace);
223+
}
245224

246-
// Configure the locators with the modified config.
247-
for locator in context.locators.iter() {
248-
locator.configure(&config);
249-
}
250-
} else if let Some(search_kind) = refresh_options.search_kind {
251-
// When only search_kind is provided, keep workspace directories so that
252-
// workspace-based environments (Venv, VirtualEnv, etc.) can be found.
253-
// The search_scope tells find_and_report_envs to filter locators by kind.
254-
search_scope = Some(SearchScope::Global(search_kind));
255-
256-
// Re-configure the locators (config unchanged, but ensures consistency).
257-
for locator in context.locators.iter() {
258-
locator.configure(&config);
259-
}
260-
} else {
261-
// Re-configure the locators with an un-modified config.
262-
// Possible we congirued the locators with a modified config in the in the previous request.
263-
// & the config was scoped to a particular search folder, executables or kind.
264-
for locator in context.locators.iter() {
265-
locator.configure(&config);
266-
}
225+
// Configure the locators with the (possibly modified) config.
226+
for locator in context.locators.iter() {
227+
locator.configure(&config);
267228
}
268229

269230
trace!("Start refreshing environments, config: {:?}", config);
@@ -506,3 +467,152 @@ pub fn handle_clear_cache(_context: Arc<Context>, id: u32, _params: Value) {
506467
}
507468
});
508469
}
470+
471+
/// Builds the configuration and search scope based on refresh options.
472+
/// This is extracted from handle_refresh to enable unit testing.
473+
///
474+
/// Returns (modified_config, search_scope)
475+
pub fn build_refresh_config(
476+
refresh_options: &RefreshOptions,
477+
mut config: Configuration,
478+
) -> (Configuration, Option<SearchScope>) {
479+
let mut search_scope = None;
480+
481+
// If search_paths is provided, limit search to those paths.
482+
// If only search_kind is provided (without search_paths), we still search
483+
// workspace directories because many environment types (like Venv, VirtualEnv)
484+
// don't have global locations - they only exist in workspace folders.
485+
// The reporter will filter results to only report the requested kind.
486+
if let Some(ref search_paths) = refresh_options.search_paths {
487+
// Clear workspace directories when explicit search paths are provided.
488+
config.workspace_directories = None;
489+
// Expand any glob patterns in the search paths
490+
let expanded_paths = expand_glob_patterns(search_paths);
491+
// These workspace folders are only for this refresh.
492+
config.workspace_directories = Some(
493+
expanded_paths
494+
.iter()
495+
.filter(|p| p.is_dir())
496+
.cloned()
497+
.collect(),
498+
);
499+
config.executables = Some(
500+
expanded_paths
501+
.iter()
502+
.filter(|p| p.is_file())
503+
.cloned()
504+
.collect(),
505+
);
506+
search_scope = Some(SearchScope::Workspace);
507+
} else if let Some(search_kind) = refresh_options.search_kind {
508+
// When only search_kind is provided, keep workspace directories so that
509+
// workspace-based environments (Venv, VirtualEnv, etc.) can be found.
510+
// The search_scope tells find_and_report_envs to filter locators by kind.
511+
search_scope = Some(SearchScope::Global(search_kind));
512+
}
513+
514+
(config, search_scope)
515+
}
516+
517+
#[cfg(test)]
518+
mod tests {
519+
use super::*;
520+
use std::path::PathBuf;
521+
522+
/// Test for https://github.com/microsoft/python-environment-tools/issues/151
523+
/// Verifies that when searchKind is provided (without searchPaths),
524+
/// workspace_directories are NOT cleared.
525+
///
526+
/// The bug was that handle_refresh cleared workspace_directories when searchKind
527+
/// was provided, preventing discovery of workspace-based environments like venvs.
528+
#[test]
529+
fn test_search_kind_preserves_workspace_directories() {
530+
let workspace = PathBuf::from("/test/workspace");
531+
let config = Configuration {
532+
workspace_directories: Some(vec![workspace.clone()]),
533+
..Default::default()
534+
};
535+
536+
let refresh_options = RefreshOptions {
537+
search_kind: Some(PythonEnvironmentKind::Venv),
538+
search_paths: None,
539+
};
540+
541+
let (result_config, search_scope) = build_refresh_config(&refresh_options, config);
542+
543+
// CRITICAL: workspace_directories must be preserved when only search_kind is provided
544+
assert_eq!(
545+
result_config.workspace_directories,
546+
Some(vec![workspace]),
547+
"workspace_directories should NOT be cleared when only searchKind is provided"
548+
);
549+
550+
// search_scope should be Global with the requested kind
551+
assert!(
552+
matches!(
553+
search_scope,
554+
Some(SearchScope::Global(PythonEnvironmentKind::Venv))
555+
),
556+
"search_scope should be Global(Venv)"
557+
);
558+
}
559+
560+
/// Test that when searchPaths is provided, workspace_directories ARE replaced.
561+
#[test]
562+
fn test_search_paths_replaces_workspace_directories() {
563+
let original_workspace = PathBuf::from("/original/workspace");
564+
let config = Configuration {
565+
workspace_directories: Some(vec![original_workspace]),
566+
..Default::default()
567+
};
568+
569+
// Note: search_paths won't exist as directories in tests, so expanded result will be empty
570+
let refresh_options = RefreshOptions {
571+
search_kind: None,
572+
search_paths: Some(vec![PathBuf::from("/search/path")]),
573+
};
574+
575+
let (result_config, search_scope) = build_refresh_config(&refresh_options, config);
576+
577+
// workspace_directories should be replaced (empty since paths don't exist)
578+
assert_eq!(
579+
result_config.workspace_directories,
580+
Some(vec![]),
581+
"workspace_directories should be replaced by search_paths"
582+
);
583+
584+
assert!(
585+
matches!(search_scope, Some(SearchScope::Workspace)),
586+
"search_scope should be Workspace"
587+
);
588+
}
589+
590+
/// Test that when neither searchKind nor searchPaths is provided,
591+
/// configuration is unchanged.
592+
#[test]
593+
fn test_no_options_preserves_config() {
594+
let workspace = PathBuf::from("/test/workspace");
595+
let config = Configuration {
596+
workspace_directories: Some(vec![workspace.clone()]),
597+
..Default::default()
598+
};
599+
600+
let refresh_options = RefreshOptions {
601+
search_kind: None,
602+
search_paths: None,
603+
};
604+
605+
let (result_config, search_scope) = build_refresh_config(&refresh_options, config);
606+
607+
assert_eq!(
608+
result_config.workspace_directories,
609+
Some(vec![workspace]),
610+
"workspace_directories should be preserved when no options provided"
611+
);
612+
613+
assert!(
614+
search_scope.is_none(),
615+
"search_scope should be None when no options provided"
616+
);
617+
}
618+
}

0 commit comments

Comments
 (0)