@@ -12,7 +12,76 @@ use pet_fs::path::resolve_symlink;
1212use pet_python_utils:: version;
1313use pet_python_utils:: { env:: ResolvedPythonEnv , executable:: find_executables} ;
1414use pet_virtualenv:: is_virtualenv;
15- use std:: path:: PathBuf ;
15+ use std:: path:: { Path , PathBuf } ;
16+
17+ /// Returns `true` when `name` is `python`, `python3`, or `python3.<minor>`
18+ /// (where minor is one or more ASCII digits).
19+ fn is_macos_python_executable_name ( name : & str ) -> bool {
20+ if name == "python" || name == "python3" {
21+ return true ;
22+ }
23+ if let Some ( rest) = name. strip_prefix ( "python3." ) {
24+ return !rest. is_empty ( ) && rest. chars ( ) . all ( |c| c. is_ascii_digit ( ) ) ;
25+ }
26+ false
27+ }
28+
29+ /// Returns `true` when `dir` is `"Current"` or a `<major>.<minor>` pair of
30+ /// ASCII digits (e.g. `"3.9"`).
31+ fn is_valid_framework_version_dir ( dir : & str ) -> bool {
32+ if dir == "Current" {
33+ return true ;
34+ }
35+ match dir. split_once ( '.' ) {
36+ Some ( ( major, minor) ) => {
37+ !major. is_empty ( )
38+ && major. chars ( ) . all ( |c| c. is_ascii_digit ( ) )
39+ && !minor. is_empty ( )
40+ && minor. chars ( ) . all ( |c| c. is_ascii_digit ( ) )
41+ }
42+ None => false ,
43+ }
44+ }
45+
46+ /// Checks whether the given path (as a pre-computed lossy string) is a valid
47+ /// Command Line Tools Python executable path.
48+ ///
49+ /// Accepted shapes:
50+ /// /Library/Developer/CommandLineTools/usr/bin/<python-exe>
51+ /// /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/<ver>/bin/<python-exe>
52+ fn is_cmdlinetools_python_path ( path_str : & str ) -> bool {
53+ let path = Path :: new ( path_str) ;
54+
55+ let exe_name = match path. file_name ( ) . and_then ( |n| n. to_str ( ) ) {
56+ Some ( name) => name,
57+ None => return false ,
58+ } ;
59+
60+ if !is_macos_python_executable_name ( exe_name) {
61+ return false ;
62+ }
63+
64+ // Shape 1: /Library/Developer/CommandLineTools/usr/bin/<exe>
65+ if let Ok ( rest) = path. strip_prefix ( "/Library/Developer/CommandLineTools/usr/bin" ) {
66+ return rest. components ( ) . count ( ) == 1 ;
67+ }
68+
69+ // Shape 2: .../Python3.framework/Versions/<ver>/bin/<exe>
70+ if let Ok ( rest) = path. strip_prefix (
71+ "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions" ,
72+ ) {
73+ let components: Vec < _ > = rest. components ( ) . collect ( ) ;
74+ // Expect exactly 3 components: <version>, "bin", <exe>
75+ if components. len ( ) != 3 {
76+ return false ;
77+ }
78+ let version_dir = components[ 0 ] . as_os_str ( ) . to_string_lossy ( ) ;
79+ let bin_dir = components[ 1 ] . as_os_str ( ) . to_string_lossy ( ) ;
80+ return is_valid_framework_version_dir ( & version_dir) && bin_dir == "bin" ;
81+ }
82+
83+ false
84+ }
1685
1786pub struct MacCmdLineTools { }
1887
@@ -45,13 +114,8 @@ impl Locator for MacCmdLineTools {
45114 return None ;
46115 }
47116
48- if !env
49- . executable
50- . starts_with ( "/Library/Developer/CommandLineTools/usr/bin" )
51- && !env. executable . starts_with (
52- "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions" ,
53- )
54- {
117+ let exe_str = env. executable . to_string_lossy ( ) ;
118+ if !is_cmdlinetools_python_path ( & exe_str) {
55119 return None ;
56120 }
57121
@@ -246,3 +310,221 @@ impl Locator for MacCmdLineTools {
246310 }
247311 }
248312}
313+
314+ #[ cfg( test) ]
315+ mod tests {
316+ use super :: * ;
317+ use pet_core:: { python_environment:: PythonEnvironmentKind , Locator , LocatorKind } ;
318+
319+ // ── locator metadata ──────────────────────────────────────────
320+
321+ #[ test]
322+ fn locator_metadata_matches_cmdlinetools_kind ( ) {
323+ let loc = MacCmdLineTools :: new ( ) ;
324+ assert_eq ! ( loc. get_kind( ) , LocatorKind :: MacCommandLineTools ) ;
325+ assert_eq ! (
326+ loc. supported_categories( ) ,
327+ vec![ PythonEnvironmentKind :: MacCommandLineTools ]
328+ ) ;
329+ }
330+
331+ // ── is_macos_python_executable_name ───────────────────────────
332+
333+ #[ test]
334+ fn exe_name_accepts_python ( ) {
335+ assert ! ( is_macos_python_executable_name( "python" ) ) ;
336+ }
337+
338+ #[ test]
339+ fn exe_name_accepts_python3 ( ) {
340+ assert ! ( is_macos_python_executable_name( "python3" ) ) ;
341+ }
342+
343+ #[ test]
344+ fn exe_name_accepts_python3_minor ( ) {
345+ assert ! ( is_macos_python_executable_name( "python3.9" ) ) ;
346+ assert ! ( is_macos_python_executable_name( "python3.12" ) ) ;
347+ }
348+
349+ #[ test]
350+ fn exe_name_rejects_config_script ( ) {
351+ assert ! ( !is_macos_python_executable_name( "python3-config" ) ) ;
352+ assert ! ( !is_macos_python_executable_name( "python3.9-config" ) ) ;
353+ }
354+
355+ #[ test]
356+ fn exe_name_rejects_non_python_tools ( ) {
357+ assert ! ( !is_macos_python_executable_name( "idle3" ) ) ;
358+ assert ! ( !is_macos_python_executable_name( "pydoc3" ) ) ;
359+ assert ! ( !is_macos_python_executable_name( "pip3" ) ) ;
360+ }
361+
362+ #[ test]
363+ fn exe_name_rejects_compact_version ( ) {
364+ assert ! ( !is_macos_python_executable_name( "python39" ) ) ;
365+ }
366+
367+ #[ test]
368+ fn exe_name_rejects_multi_dot_version ( ) {
369+ assert ! ( !is_macos_python_executable_name( "python3.9.1" ) ) ;
370+ }
371+
372+ #[ test]
373+ fn exe_name_rejects_trailing_dot ( ) {
374+ assert ! ( !is_macos_python_executable_name( "python3." ) ) ;
375+ }
376+
377+ // ── is_valid_framework_version_dir ────────────────────────────
378+
379+ #[ test]
380+ fn version_dir_accepts_current ( ) {
381+ assert ! ( is_valid_framework_version_dir( "Current" ) ) ;
382+ }
383+
384+ #[ test]
385+ fn version_dir_accepts_major_minor ( ) {
386+ assert ! ( is_valid_framework_version_dir( "3.9" ) ) ;
387+ assert ! ( is_valid_framework_version_dir( "3.12" ) ) ;
388+ }
389+
390+ #[ test]
391+ fn version_dir_rejects_patch_version ( ) {
392+ assert ! ( !is_valid_framework_version_dir( "3.9.1" ) ) ;
393+ }
394+
395+ #[ test]
396+ fn version_dir_rejects_bare_major ( ) {
397+ assert ! ( !is_valid_framework_version_dir( "3" ) ) ;
398+ }
399+
400+ #[ test]
401+ fn version_dir_rejects_empty ( ) {
402+ assert ! ( !is_valid_framework_version_dir( "" ) ) ;
403+ }
404+
405+ #[ test]
406+ fn version_dir_rejects_dot_only ( ) {
407+ assert ! ( !is_valid_framework_version_dir( "." ) ) ;
408+ assert ! ( !is_valid_framework_version_dir( "3." ) ) ;
409+ assert ! ( !is_valid_framework_version_dir( ".9" ) ) ;
410+ }
411+
412+ // ── is_cmdlinetools_python_path ───────────────────────────────
413+
414+ #[ test]
415+ fn cmdlinetools_path_accepts_usr_bin_python3 ( ) {
416+ assert ! ( is_cmdlinetools_python_path(
417+ "/Library/Developer/CommandLineTools/usr/bin/python3"
418+ ) ) ;
419+ }
420+
421+ #[ test]
422+ fn cmdlinetools_path_accepts_usr_bin_python3_versioned ( ) {
423+ assert ! ( is_cmdlinetools_python_path(
424+ "/Library/Developer/CommandLineTools/usr/bin/python3.9"
425+ ) ) ;
426+ }
427+
428+ #[ test]
429+ fn cmdlinetools_path_accepts_usr_bin_python ( ) {
430+ assert ! ( is_cmdlinetools_python_path(
431+ "/Library/Developer/CommandLineTools/usr/bin/python"
432+ ) ) ;
433+ }
434+
435+ #[ test]
436+ fn cmdlinetools_path_accepts_framework_versioned_python ( ) {
437+ assert ! ( is_cmdlinetools_python_path(
438+ "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9"
439+ ) ) ;
440+ }
441+
442+ #[ test]
443+ fn cmdlinetools_path_accepts_framework_current_version ( ) {
444+ assert ! ( is_cmdlinetools_python_path(
445+ "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/Current/bin/python3"
446+ ) ) ;
447+ }
448+
449+ #[ test]
450+ fn cmdlinetools_path_accepts_framework_bare_python ( ) {
451+ assert ! ( is_cmdlinetools_python_path(
452+ "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python"
453+ ) ) ;
454+ }
455+
456+ #[ test]
457+ fn cmdlinetools_path_rejects_empty_string ( ) {
458+ assert ! ( !is_cmdlinetools_python_path( "" ) ) ;
459+ }
460+
461+ #[ test]
462+ fn cmdlinetools_path_rejects_config_script ( ) {
463+ assert ! ( !is_cmdlinetools_python_path(
464+ "/Library/Developer/CommandLineTools/usr/bin/python3-config"
465+ ) ) ;
466+ assert ! ( !is_cmdlinetools_python_path(
467+ "/Library/Developer/CommandLineTools/usr/bin/python3.9-config"
468+ ) ) ;
469+ }
470+
471+ #[ test]
472+ fn cmdlinetools_path_rejects_non_python_tools ( ) {
473+ assert ! ( !is_cmdlinetools_python_path(
474+ "/Library/Developer/CommandLineTools/usr/bin/idle3"
475+ ) ) ;
476+ assert ! ( !is_cmdlinetools_python_path(
477+ "/Library/Developer/CommandLineTools/usr/bin/pydoc3"
478+ ) ) ;
479+ }
480+
481+ #[ test]
482+ fn cmdlinetools_path_rejects_framework_invalid_version ( ) {
483+ assert ! ( !is_cmdlinetools_python_path(
484+ "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9.1/bin/python3.9"
485+ ) ) ;
486+ }
487+
488+ #[ test]
489+ fn cmdlinetools_path_rejects_framework_nested_path ( ) {
490+ assert ! ( !is_cmdlinetools_python_path(
491+ "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/subdir/python3.9"
492+ ) ) ;
493+ }
494+
495+ #[ test]
496+ fn cmdlinetools_path_rejects_unrelated_path ( ) {
497+ assert ! ( !is_cmdlinetools_python_path( "/usr/bin/python3" ) ) ;
498+ assert ! ( !is_cmdlinetools_python_path( "/usr/local/bin/python3" ) ) ;
499+ assert ! ( !is_cmdlinetools_python_path( "/opt/homebrew/bin/python3.11" ) ) ;
500+ }
501+
502+ #[ test]
503+ fn cmdlinetools_path_rejects_xcode_path ( ) {
504+ assert ! ( !is_cmdlinetools_python_path(
505+ "/Applications/Xcode.app/Contents/Developer/usr/bin/python3"
506+ ) ) ;
507+ }
508+
509+ #[ test]
510+ fn cmdlinetools_path_rejects_usr_bin_nested_deeper ( ) {
511+ assert ! ( !is_cmdlinetools_python_path(
512+ "/Library/Developer/CommandLineTools/usr/bin/subdir/python3"
513+ ) ) ;
514+ }
515+
516+ #[ test]
517+ fn try_from_rejects_cmdlinetools_path_off_macos ( ) {
518+ // On non-macOS the locator always returns None regardless of path.
519+ if std:: env:: consts:: OS == "macos" {
520+ return ; // skip on macOS — this test is for other platforms
521+ }
522+ let loc = MacCmdLineTools :: new ( ) ;
523+ let env = PythonEnv :: new (
524+ PathBuf :: from ( "/Library/Developer/CommandLineTools/usr/bin/python3.9" ) ,
525+ None ,
526+ None ,
527+ ) ;
528+ assert ! ( loc. try_from( & env) . is_none( ) ) ;
529+ }
530+ }
0 commit comments