Skip to content

Commit d5fcd19

Browse files
authored
fix: ensure workspace environments are found when searchKind is provided (#346)
Fixes #151
1 parent 1a479a4 commit d5fcd19

File tree

2 files changed

+171
-48
lines changed

2 files changed

+171
-48
lines changed

crates/pet/src/jsonrpc.rs

Lines changed: 168 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -204,57 +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 kind is provided and no search_paths, then we will only search in the global locations.
215-
if refresh_options.search_kind.is_some() || refresh_options.search_paths.is_some() {
216-
// Always clear this, as we will either serach in specified folder or a specific kind in global locations.
217-
config.workspace_directories = None;
218-
if let Some(search_paths) = refresh_options.search_paths {
219-
// Expand any glob patterns in the search paths
220-
let expanded_paths = expand_glob_patterns(&search_paths);
221-
trace!(
222-
"Expanded {} search paths to {} paths",
223-
search_paths.len(),
224-
expanded_paths.len()
225-
);
226-
// These workspace folders are only for this refresh.
227-
config.workspace_directories = Some(
228-
expanded_paths
229-
.iter()
230-
.filter(|p| p.is_dir())
231-
.cloned()
232-
.collect(),
233-
);
234-
config.executables = Some(
235-
expanded_paths
236-
.iter()
237-
.filter(|p| p.is_file())
238-
.cloned()
239-
.collect(),
240-
);
241-
search_scope = Some(SearchScope::Workspace);
242-
} else if let Some(search_kind) = refresh_options.search_kind {
243-
config.executables = None;
244-
search_scope = Some(SearchScope::Global(search_kind));
245-
}
212+
let (config, search_scope) = build_refresh_config(&refresh_options, config);
213+
if refresh_options.search_paths.is_some() {
214+
trace!(
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)
222+
);
223+
}
246224

247-
// Configure the locators with the modified config.
248-
for locator in context.locators.iter() {
249-
locator.configure(&config);
250-
}
251-
} else {
252-
// Re-configure the locators with an un-modified config.
253-
// Possible we congirued the locators with a modified config in the in the previous request.
254-
// & the config was scoped to a particular search folder, executables or kind.
255-
for locator in context.locators.iter() {
256-
locator.configure(&config);
257-
}
225+
// Configure the locators with the (possibly modified) config.
226+
for locator in context.locators.iter() {
227+
locator.configure(&config);
258228
}
259229

260230
trace!("Start refreshing environments, config: {:?}", config);
@@ -497,3 +467,155 @@ pub fn handle_clear_cache(_context: Arc<Context>, id: u32, _params: Value) {
497467
}
498468
});
499469
}
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(crate) 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 temp_dir = tempfile::tempdir().unwrap();
564+
let search_dir = temp_dir.path().join("search_path");
565+
std::fs::create_dir(&search_dir).unwrap();
566+
567+
let original_workspace = PathBuf::from("/original/workspace");
568+
let config = Configuration {
569+
workspace_directories: Some(vec![original_workspace]),
570+
..Default::default()
571+
};
572+
573+
let refresh_options = RefreshOptions {
574+
search_kind: None,
575+
search_paths: Some(vec![search_dir.clone()]),
576+
};
577+
578+
let (result_config, search_scope) = build_refresh_config(&refresh_options, config);
579+
580+
// workspace_directories should be replaced with the search_paths directory
581+
assert_eq!(
582+
result_config.workspace_directories,
583+
Some(vec![search_dir]),
584+
"workspace_directories should be replaced by search_paths"
585+
);
586+
587+
assert!(
588+
matches!(search_scope, Some(SearchScope::Workspace)),
589+
"search_scope should be Workspace"
590+
);
591+
}
592+
593+
/// Test that when neither searchKind nor searchPaths is provided,
594+
/// configuration is unchanged.
595+
#[test]
596+
fn test_no_options_preserves_config() {
597+
let workspace = PathBuf::from("/test/workspace");
598+
let config = Configuration {
599+
workspace_directories: Some(vec![workspace.clone()]),
600+
..Default::default()
601+
};
602+
603+
let refresh_options = RefreshOptions {
604+
search_kind: None,
605+
search_paths: None,
606+
};
607+
608+
let (result_config, search_scope) = build_refresh_config(&refresh_options, config);
609+
610+
assert_eq!(
611+
result_config.workspace_directories,
612+
Some(vec![workspace]),
613+
"workspace_directories should be preserved when no options provided"
614+
);
615+
616+
assert!(
617+
search_scope.is_none(),
618+
"search_scope should be None when no options provided"
619+
);
620+
}
621+
}

docs/JSONRPC.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,15 @@ _Response_:
9797
interface RefreshParams {
9898
/**
9999
* Limits the search to a specific kind of Python environment.
100-
* Ignores workspace folders passed in configuration request.
100+
* Workspace folders from the configuration request are still searched
101+
* to discover workspace-based environments (e.g., venvs, virtualenvs).
101102
*/
102103
searchKind?: PythonEnvironmentKind;
103104
} | {
104105
/**
105106
* Limits the search to a specific set of paths.
106107
* searchPaths can either by directories or Python prefixes/executables or combination of both.
107-
* Ignores workspace folders passed in configuration request.
108+
* Replaces workspace folders from the configuration request.
108109
*
109110
* Glob patterns are supported:
110111
* - `*` matches any sequence of characters in a path component

0 commit comments

Comments
 (0)