Skip to content

Commit e53972f

Browse files
committed
test: add Mac Command Line Tools locator coverage (Fixes #389)
1 parent e9e15dc commit e53972f

1 file changed

Lines changed: 290 additions & 8 deletions

File tree

  • crates/pet-mac-commandlinetools/src

crates/pet-mac-commandlinetools/src/lib.rs

Lines changed: 290 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,76 @@ use pet_fs::path::resolve_symlink;
1212
use pet_python_utils::version;
1313
use pet_python_utils::{env::ResolvedPythonEnv, executable::find_executables};
1414
use 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

1786
pub 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

Comments
 (0)