@@ -377,10 +377,20 @@ fn build_environment(
377377/// directly so callers always go through the cache.
378378#[ cfg( windows) ]
379379fn discover_environments ( locator : & WinPython ) -> Vec < PythonEnvironment > {
380+ discover_environments_in ( locator, get_winpython_search_paths ( ) )
381+ }
382+
383+ /// Testable variant of [`discover_environments`] that takes the list of
384+ /// search paths as input rather than reading environment variables.
385+ #[ cfg( any( windows, test) ) ]
386+ fn discover_environments_in (
387+ locator : & WinPython ,
388+ search_paths : Vec < PathBuf > ,
389+ ) -> Vec < PythonEnvironment > {
380390 let mut found: Vec < PythonEnvironment > = Vec :: new ( ) ;
381391 let mut seen_executables: std:: collections:: HashSet < PathBuf > = std:: collections:: HashSet :: new ( ) ;
382392
383- for search_path in get_winpython_search_paths ( ) {
393+ for search_path in search_paths {
384394 if !search_path. exists ( ) {
385395 continue ;
386396 }
@@ -395,6 +405,7 @@ fn discover_environments(locator: &WinPython) -> Vec<PythonEnvironment> {
395405 . is_some_and ( is_winpython_dir_name)
396406 {
397407 collect_install ( & search_path, locator, & mut found, & mut seen_executables) ;
408+ continue ;
398409 }
399410
400411 // Otherwise treat it as a directory that may contain WinPython installs.
@@ -419,7 +430,7 @@ fn discover_environments(locator: &WinPython) -> Vec<PythonEnvironment> {
419430 found
420431}
421432
422- #[ cfg( windows) ]
433+ #[ cfg( any ( windows, test ) ) ]
423434fn collect_install (
424435 winpython_root : & Path ,
425436 locator : & WinPython ,
@@ -821,4 +832,204 @@ mod tests {
821832 let paths = build_search_paths ( Some ( home) , Some ( extra) ) ;
822833 assert_eq ! ( paths, vec![ norm_case( default_path) ] ) ;
823834 }
835+
836+ /// Build a minimal on-disk WinPython tree under `parent`:
837+ ///
838+ /// ```text
839+ /// parent/<name>/
840+ /// .winpython
841+ /// python-3.13.0.amd64/
842+ /// python.exe
843+ /// ```
844+ ///
845+ /// Returns the WinPython root and the python.exe path.
846+ #[ cfg( windows) ]
847+ fn make_winpython_tree ( parent : & Path , name : & str ) -> ( PathBuf , PathBuf ) {
848+ let root = parent. join ( name) ;
849+ fs:: create_dir_all ( & root) . unwrap ( ) ;
850+ File :: create ( root. join ( ".winpython" ) ) . unwrap ( ) ;
851+
852+ let python_folder = root. join ( "python-3.13.0.amd64" ) ;
853+ fs:: create_dir_all ( & python_folder) . unwrap ( ) ;
854+ let python_exe = python_folder. join ( if cfg ! ( windows) {
855+ "python.exe"
856+ } else {
857+ "python"
858+ } ) ;
859+ File :: create ( & python_exe) . unwrap ( ) ;
860+
861+ ( root, python_exe)
862+ }
863+
864+ /// `discover_environments_in` should find an install when the search path
865+ /// is a *parent directory* containing one or more WPy* directories.
866+ #[ test]
867+ #[ cfg( windows) ]
868+ fn test_discover_environments_in_parent_dir ( ) {
869+ let dir = tempdir ( ) . unwrap ( ) ;
870+ let ( _root, python_exe) = make_winpython_tree ( dir. path ( ) , "WPy64-31300" ) ;
871+
872+ let locator = WinPython :: new ( ) ;
873+ let envs = discover_environments_in ( & locator, vec ! [ dir. path( ) . to_path_buf( ) ] ) ;
874+
875+ assert_eq ! ( envs. len( ) , 1 , "expected one env, got {envs:?}" ) ;
876+ let env = & envs[ 0 ] ;
877+ assert_eq ! ( env. kind, Some ( PythonEnvironmentKind :: WinPython ) ) ;
878+ assert_eq ! (
879+ env. executable. as_deref( ) ,
880+ Some ( norm_case( & python_exe) . as_path( ) )
881+ ) ;
882+ }
883+
884+ /// `discover_environments_in` should also accept a path that *is itself*
885+ /// a WinPython install (the `WINPYTHON_HOME=D:\WPy64-31300` shape).
886+ #[ test]
887+ #[ cfg( windows) ]
888+ fn test_discover_environments_in_direct_install ( ) {
889+ let dir = tempdir ( ) . unwrap ( ) ;
890+ let ( root, _python_exe) = make_winpython_tree ( dir. path ( ) , "WPy64-31300" ) ;
891+
892+ let locator = WinPython :: new ( ) ;
893+ let envs = discover_environments_in ( & locator, vec ! [ root] ) ;
894+
895+ assert_eq ! ( envs. len( ) , 1 ) ;
896+ }
897+
898+ /// Two WPy* directories under the same parent should both be discovered,
899+ /// and we should not double-report when the same install is reachable
900+ /// via two different search paths (dedup by normalized executable).
901+ #[ test]
902+ #[ cfg( windows) ]
903+ fn test_discover_environments_in_dedups ( ) {
904+ let dir = tempdir ( ) . unwrap ( ) ;
905+ let ( root_a, _) = make_winpython_tree ( dir. path ( ) , "WPy64-31300" ) ;
906+ make_winpython_tree ( dir. path ( ) , "WPy64-31200" ) ;
907+
908+ let locator = WinPython :: new ( ) ;
909+ let envs = discover_environments_in (
910+ & locator,
911+ // Pass parent twice + a direct install to exercise dedup.
912+ vec ! [ dir. path( ) . to_path_buf( ) , dir. path( ) . to_path_buf( ) , root_a] ,
913+ ) ;
914+
915+ assert_eq ! ( envs. len( ) , 2 , "expected 2 unique envs, got {envs:?}" ) ;
916+ }
917+
918+ /// Non-existent search paths are skipped silently.
919+ #[ test]
920+ #[ cfg( windows) ]
921+ fn test_discover_environments_in_ignores_missing_paths ( ) {
922+ let locator = WinPython :: new ( ) ;
923+ let envs =
924+ discover_environments_in ( & locator, vec ! [ PathBuf :: from( r"Z:\definitely\not\here" ) ] ) ;
925+ assert ! ( envs. is_empty( ) ) ;
926+ }
927+
928+ /// Drive `find_with_cache`'s cached-hit path and the report loop
929+ /// (the body of `Locator::find` minus the `clear()`).
930+ #[ test]
931+ #[ cfg( windows) ]
932+ fn test_find_with_cache_iteration_reports_each_environment ( ) {
933+ use pet_core:: manager:: EnvManager ;
934+ use pet_core:: telemetry:: TelemetryEvent ;
935+ use std:: sync:: Mutex ;
936+
937+ struct Capture {
938+ envs : Mutex < Vec < PythonEnvironment > > ,
939+ }
940+ impl Reporter for Capture {
941+ fn report_manager ( & self , _: & EnvManager ) { }
942+ fn report_environment ( & self , env : & PythonEnvironment ) {
943+ self . envs . lock ( ) . unwrap ( ) . push ( env. clone ( ) ) ;
944+ }
945+ fn report_telemetry ( & self , _: & TelemetryEvent ) { }
946+ }
947+
948+ let dir = tempdir ( ) . unwrap ( ) ;
949+ let ( root, _) = make_winpython_tree ( dir. path ( ) , "WPy64-31300" ) ;
950+
951+ let locator = WinPython :: new ( ) ;
952+ let env = PythonEnv :: new (
953+ root. join ( "python-3.13.0.amd64" ) . join ( "python.exe" ) ,
954+ Some ( root. join ( "python-3.13.0.amd64" ) ) ,
955+ None ,
956+ ) ;
957+ let py_env = locator. try_from ( & env) . expect ( "try_from should succeed" ) ;
958+
959+ // Drive `find_with_cache` straight to its cached-hit branch and the
960+ // report loop without invoking `discover_environments` (which would
961+ // read real env vars).
962+ locator
963+ . cached_environments
964+ . lock ( )
965+ . unwrap ( )
966+ . replace ( Arc :: new ( vec ! [ py_env] ) ) ;
967+
968+ let reporter = Capture {
969+ envs : Mutex :: new ( Vec :: new ( ) ) ,
970+ } ;
971+ for env in locator. find_with_cache ( ) . iter ( ) {
972+ reporter. report_environment ( env) ;
973+ }
974+
975+ let captured = reporter. envs . lock ( ) . unwrap ( ) ;
976+ assert_eq ! ( captured. len( ) , 1 ) ;
977+ assert_eq ! ( captured[ 0 ] . kind, Some ( PythonEnvironmentKind :: WinPython ) ) ;
978+ }
979+
980+ /// `sync_refresh_state_from(Full)` copies the source's cached envs.
981+ #[ test]
982+ fn test_sync_refresh_state_full_scope_copies_cache ( ) {
983+ let source = WinPython :: new ( ) ;
984+ source
985+ . cached_environments
986+ . lock ( )
987+ . unwrap ( )
988+ . replace ( Arc :: new ( Vec :: new ( ) ) ) ;
989+
990+ let target = WinPython :: new ( ) ;
991+ assert ! ( target. cached_environments. lock( ) . unwrap( ) . is_none( ) ) ;
992+
993+ target. sync_refresh_state_from ( & source, & RefreshStateSyncScope :: Full ) ;
994+ assert ! ( target. cached_environments. lock( ) . unwrap( ) . is_some( ) ) ;
995+ }
996+
997+ /// `sync_refresh_state_from(GlobalFiltered(WinPython))` also syncs.
998+ #[ test]
999+ fn test_sync_refresh_state_matching_filtered_scope_copies_cache ( ) {
1000+ let source = WinPython :: new ( ) ;
1001+ source
1002+ . cached_environments
1003+ . lock ( )
1004+ . unwrap ( )
1005+ . replace ( Arc :: new ( Vec :: new ( ) ) ) ;
1006+
1007+ let target = WinPython :: new ( ) ;
1008+ target. sync_refresh_state_from (
1009+ & source,
1010+ & RefreshStateSyncScope :: GlobalFiltered ( PythonEnvironmentKind :: WinPython ) ,
1011+ ) ;
1012+ assert ! ( target. cached_environments. lock( ) . unwrap( ) . is_some( ) ) ;
1013+ }
1014+
1015+ /// `GlobalFiltered` for an unrelated kind and `Workspace` are no-ops.
1016+ #[ test]
1017+ fn test_sync_refresh_state_other_scopes_are_no_op ( ) {
1018+ let source = WinPython :: new ( ) ;
1019+ source
1020+ . cached_environments
1021+ . lock ( )
1022+ . unwrap ( )
1023+ . replace ( Arc :: new ( Vec :: new ( ) ) ) ;
1024+
1025+ let target = WinPython :: new ( ) ;
1026+ target. sync_refresh_state_from (
1027+ & source,
1028+ & RefreshStateSyncScope :: GlobalFiltered ( PythonEnvironmentKind :: Conda ) ,
1029+ ) ;
1030+ assert ! ( target. cached_environments. lock( ) . unwrap( ) . is_none( ) ) ;
1031+
1032+ target. sync_refresh_state_from ( & source, & RefreshStateSyncScope :: Workspace ) ;
1033+ assert ! ( target. cached_environments. lock( ) . unwrap( ) . is_none( ) ) ;
1034+ }
8241035}
0 commit comments