Skip to main content

simics_python_utils/
discovery.rs

1// Copyright (C) 2024 Intel Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::{environment::PackageSource, PythonEnvironment, PythonVersion};
5use anyhow::{anyhow, Result};
6use ispm_wrapper::{
7    data::InstalledPackage,
8    ispm::{self, GlobalOptions},
9};
10use std::{
11    env,
12    fs::read_dir,
13    path::{Path, PathBuf},
14};
15use versions::Versioning;
16
17#[cfg(not(windows))]
18/// The name of the binary/library/object subdirectory on linux systems
19pub const HOST_DIRNAME: &str = "linux64";
20
21#[cfg(windows)]
22/// The name of the binary/library/object subdirectory on Windows systems
23pub const HOST_DIRNAME: &str = "win64";
24
25/// Environment variable name for Python include path override
26pub const PYTHON3_INCLUDE_ENV: &str = "PYTHON3_INCLUDE";
27/// Environment variable name for Python library path override
28pub const PYTHON3_LDFLAGS_ENV: &str = "PYTHON3_LDFLAGS";
29
30// ============================================================================
31// Platform Configuration - All platform differences centralized here
32// ============================================================================
33
34/// Platform-specific configuration for Python library discovery
35#[derive(Debug, Clone)]
36struct PlatformConfig {
37    /// Subdirectory components from base_path to library directory
38    /// Unix: ["sys", "lib"], Windows: ["lib"]
39    lib_subdir: &'static [&'static str],
40    /// Whether the lib directory contains a python3.X version subdirectory
41    /// Unix: false (libs directly in sys/lib/), Windows: true (libs in lib/python3.X/)
42    lib_has_version_subdir: bool,
43    /// Prefix for Python library files (e.g., "libpython" or "python3")
44    lib_prefix: &'static str,
45    /// Extension for Python library files (e.g., ".so" or ".dll")
46    lib_extension: &'static str,
47    /// Generic library name to filter out in favor of versioned one
48    generic_lib_name: &'static str,
49}
50
51#[cfg(unix)]
52const PLATFORM_CONFIG: PlatformConfig = PlatformConfig {
53    lib_subdir: &["sys", "lib"],
54    lib_has_version_subdir: false,
55    lib_prefix: "libpython",
56    lib_extension: ".so",
57    generic_lib_name: "libpython3.so",
58};
59
60#[cfg(windows)]
61const PLATFORM_CONFIG: PlatformConfig = PlatformConfig {
62    lib_subdir: &["lib"],
63    lib_has_version_subdir: true,
64    lib_prefix: "python3",
65    lib_extension: ".dll",
66    generic_lib_name: "python3.dll",
67};
68
69// ============================================================================
70// Core Discovery Logic - Platform agnostic, testable
71// ============================================================================
72
73/// Check if a filename matches the Python library pattern for a given config
74fn is_python_library_name(name: &str, config: &PlatformConfig) -> bool {
75    name.starts_with(config.lib_prefix) && name.contains(config.lib_extension)
76}
77
78/// Filter library files to prefer versioned ones over generic
79fn filter_python_libraries_with_config(files: &mut Vec<PathBuf>, config: &PlatformConfig) {
80    // Remove generic library in favor of versioned one
81    if files.len() > 1 {
82        files.retain(|path| {
83            path.file_name()
84                .and_then(|name| name.to_str())
85                .map(|name| name != config.generic_lib_name)
86                .unwrap_or(false)
87        });
88    }
89
90    // On Unix, also prefer .so.X.Y over plain .so (e.g., libpython3.9.so.1.0 over libpython3.9.so)
91    if config.lib_extension == ".so" && files.len() > 1 {
92        files.retain(|path| {
93            path.file_name()
94                .and_then(|name| name.to_str())
95                .map(|name| !name.ends_with(".so"))
96                .unwrap_or(false)
97        });
98    }
99}
100
101/// Build library directory path from base path and config
102fn get_lib_dir(base_path: &Path, config: &PlatformConfig) -> Result<PathBuf> {
103    let base = config
104        .lib_subdir
105        .iter()
106        .fold(base_path.to_path_buf(), |p, component| p.join(component));
107    if config.lib_has_version_subdir {
108        find_python_subdir(&base)
109    } else {
110        Ok(base)
111    }
112}
113
114/// Find Python library in a directory using the given config
115fn find_libpython_in_dir_with_config(
116    lib_dir: &Path,
117    config: &PlatformConfig,
118) -> Result<(PathBuf, PathBuf)> {
119    if !lib_dir.exists() {
120        return Err(anyhow!(
121            "Library directory does not exist: {}",
122            lib_dir.display()
123        ));
124    }
125
126    let entries = read_dir(lib_dir).map_err(|e| {
127        anyhow!(
128            "Failed to read library directory {}: {}",
129            lib_dir.display(),
130            e
131        )
132    })?;
133
134    let mut libpython_files: Vec<PathBuf> = entries
135        .filter_map(|entry| entry.ok())
136        .map(|entry| entry.path())
137        .filter(|path| path.is_file())
138        .filter(|path| {
139            path.file_name()
140                .and_then(|name| name.to_str())
141                .map(|name| is_python_library_name(name, config))
142                .unwrap_or(false)
143        })
144        .collect();
145
146    filter_python_libraries_with_config(&mut libpython_files, config);
147
148    match libpython_files.len() {
149        0 => Err(anyhow!(
150            "No Python library file found in {}",
151            lib_dir.display()
152        )),
153        1 => {
154            let lib_path = libpython_files
155                .into_iter()
156                .next()
157                .expect("exactly one element guaranteed by match arm");
158            Ok((lib_dir.to_path_buf(), lib_path))
159        }
160        _ => Err(anyhow!(
161            "Multiple Python library files found in {}, expected exactly one",
162            lib_dir.display()
163        )),
164    }
165}
166
167// ============================================================================
168// ISPM Package Discovery
169// ============================================================================
170
171/// Find the latest Python package (1033) for a given Simics major version using ISPM API
172fn find_latest_python_package(simics_major_version: u32) -> Result<InstalledPackage> {
173    let packages = ispm::packages::list(&GlobalOptions::default())
174        .map_err(|e| anyhow!("Failed to query ISPM for installed packages: {}", e))?;
175
176    let installed = packages
177        .installed_packages
178        .as_ref()
179        .ok_or_else(|| anyhow!("No installed packages found"))?;
180
181    // Filter for Python packages (1033) matching the major version
182    let python_packages: Vec<_> = installed
183        .iter()
184        .filter(|pkg| pkg.package_number == 1033)
185        .filter(|pkg| pkg.name == "Python")
186        .filter(|pkg| {
187            // Check if the version starts with the expected major version
188            pkg.version
189                .starts_with(&format!("{}.", simics_major_version))
190        })
191        .collect();
192
193    if python_packages.is_empty() {
194        return Err(anyhow!(
195            "No Python packages found for Simics major version {}",
196            simics_major_version
197        ));
198    }
199
200    // Find the package with the highest version
201    let latest_package = python_packages
202        .iter()
203        .max_by(|a, b| {
204            let version_a = Versioning::new(&a.version).unwrap_or_default();
205            let version_b = Versioning::new(&b.version).unwrap_or_default();
206            version_a.cmp(&version_b)
207        })
208        .ok_or_else(|| anyhow!("Failed to find latest Python package"))?;
209
210    Ok((*latest_package).clone())
211}
212
213/// Extract Simics major version using hints and fallbacks
214fn detect_simics_major_version_from_base(simics_base: &Path) -> Result<u32> {
215    if let Some(dir_name) = simics_base.file_name().and_then(|n| n.to_str()) {
216        // Look for patterns like "simics-7.57.0" or "simics-6.0.191"
217        if let Some(version_part) = dir_name.strip_prefix("simics-") {
218            if let Some(major_str) = version_part.split('.').next() {
219                if let Ok(major) = major_str.parse::<u32>() {
220                    return Ok(major);
221                }
222            }
223        }
224    }
225
226    Err(anyhow!(
227        "Unable to determine Simics major version from SIMICS base path {}; expected directory name like simics-x.x.x",
228        simics_base.display()
229    ))
230}
231
232// ============================================================================
233// Public API
234// ============================================================================
235
236/// Auto-discover Python environment from SIMICS_BASE environment variable
237pub fn discover_python_environment() -> Result<PythonEnvironment> {
238    let simics_base =
239        env::var("SIMICS_BASE").map_err(|_| anyhow!("SIMICS_BASE environment variable not set"))?;
240
241    discover_python_environment_from_base(simics_base)
242}
243
244/// Discover Python environment from a specific Simics base path
245pub fn discover_python_environment_from_base<P: AsRef<Path>>(
246    simics_base: P,
247) -> Result<PythonEnvironment> {
248    let simics_base = simics_base.as_ref();
249
250    // Try bundled paths first (Simics base package 1000)
251    let bundled_err = match try_bundled_paths(simics_base) {
252        Ok(env) => return Ok(env.with_source(PackageSource::Bundled)),
253        Err(err) => err.context("Bundled discovery failed"),
254    };
255
256    // Try separate Python package discovery (Simics 1033)
257    let separate_err = match try_separate_python_package_discovery(simics_base) {
258        Ok(env) => return Ok(env.with_source(PackageSource::SeparatePackage)),
259        Err(err) => err.context("Separate package discovery failed"),
260    };
261
262    Err(anyhow!(
263        "Python environment not found in bundled location ({}) or through separate package discovery\nBundled error: {:#}\nSeparate error: {:#}",
264        simics_base.join(HOST_DIRNAME).display(),
265        bundled_err,
266        separate_err
267    ))
268}
269
270/// Try to discover Python environment from bundled Simics base package paths
271fn try_bundled_paths(simics_base: &Path) -> Result<PythonEnvironment> {
272    let base_path = simics_base.join(HOST_DIRNAME);
273    discover_from_base_path(base_path)
274}
275
276/// Try to discover Python environment using separate ISPM-based package discovery
277fn try_separate_python_package_discovery(simics_base: &Path) -> Result<PythonEnvironment> {
278    // Detect the Simics major version using multiple strategies
279    let major_version = detect_simics_major_version_from_base(simics_base)?;
280
281    // Find the latest Python package for this specific major version
282    let python_package = find_latest_python_package(major_version)?;
283
284    println!(
285        "cargo:warning=Using separate Python package: {} (version {})",
286        python_package.name, python_package.version
287    );
288
289    // Get the first path from the installed package
290    let package_path = python_package.paths.first().ok_or_else(|| {
291        anyhow!(
292            "No installation paths found for Python package {}",
293            python_package.name
294        )
295    })?;
296
297    // Construct the path to the host-specific directory
298    let python_package_path = package_path.join(HOST_DIRNAME);
299
300    discover_from_base_path(python_package_path)
301}
302
303/// Common discovery logic for both package types
304fn discover_from_base_path(base_path: PathBuf) -> Result<PythonEnvironment> {
305    // SIMICS_BASE/HOST_DIRNAME/bin/mini-python
306    let mini_python = find_mini_python(&base_path)?;
307    // SIMICS_BASE/HOST_DIRNAME/include/python3.X
308    let include_dir = find_python_include(&base_path)?;
309    // Unix: SIMICS_BASE/HOST_DIRNAME/sys/lib/libpython3.X.so.Y.Z
310    // Windows: SIMICS_BASE/HOST_DIRNAME/lib/python3.X/python3.X.dll
311    let (lib_dir, lib_path) = find_python_library(&base_path)?;
312    // Windows: directory containing python3.lib import library
313    let import_lib_dir = find_import_lib_dir(&base_path);
314    let version = PythonVersion::parse_from_include_dir(&include_dir)?;
315
316    let env = PythonEnvironment::new(
317        mini_python,
318        include_dir,
319        lib_dir,
320        lib_path,
321        import_lib_dir,
322        version,
323        PackageSource::Bundled, // Default; updated by caller
324    );
325
326    // Validate the environment before returning
327    env.validate()?;
328
329    Ok(env)
330}
331
332/// Find mini-python executable in the given base path
333fn find_mini_python(base_path: &Path) -> Result<PathBuf> {
334    #[cfg(unix)]
335    let executable_name = "mini-python";
336
337    #[cfg(windows)]
338    let executable_name = "mini-python.exe";
339
340    let mini_python_path = base_path.join("bin").join(executable_name);
341
342    if mini_python_path.exists() {
343        Ok(mini_python_path)
344    } else {
345        Err(anyhow!(
346            "Mini-python executable not found at {}",
347            mini_python_path.display()
348        ))
349    }
350}
351
352/// Find Python include directory with python3.X subdirectory
353fn find_python_include(base_path: &Path) -> Result<PathBuf> {
354    // Check environment variable first for compatibility
355    if let Ok(include_env) = env::var(PYTHON3_INCLUDE_ENV) {
356        // Extract path from -I flag if present
357        let include_path = if let Some(path) = include_env.strip_prefix("-I") {
358            PathBuf::from(path)
359        } else {
360            PathBuf::from(include_env)
361        };
362
363        if include_path.exists() {
364            return Ok(include_path);
365        }
366    }
367
368    let include_base = base_path.join("include");
369    find_python_subdir(&include_base)
370}
371
372/// Find the python3.X subdirectory in the include directory
373fn find_python_subdir(include_dir: &Path) -> Result<PathBuf> {
374    if !include_dir.exists() {
375        return Err(anyhow!(
376            "Include directory does not exist: {}",
377            include_dir.display()
378        ));
379    }
380
381    let entries = read_dir(include_dir).map_err(|e| {
382        anyhow!(
383            "Failed to read include directory {}: {}",
384            include_dir.display(),
385            e
386        )
387    })?;
388
389    let python_dirs: Vec<PathBuf> = entries
390        .filter_map(|entry| entry.ok())
391        .map(|entry| entry.path())
392        .filter(|path| path.is_dir())
393        .filter(|path| {
394            path.file_name()
395                .and_then(|name| name.to_str())
396                .map(|name| name.starts_with("python3."))
397                .unwrap_or(false)
398        })
399        .collect();
400
401    match python_dirs.len() {
402        0 => Err(anyhow!(
403            "No python3.* subdirectory found in {}",
404            include_dir.display()
405        )),
406        1 => Ok(python_dirs
407            .into_iter()
408            .next()
409            .expect("exactly one element guaranteed by match arm")),
410        _ => Err(anyhow!(
411            "Multiple python3.* subdirectories found in {}, expected exactly one",
412            include_dir.display()
413        )),
414    }
415}
416
417/// Find the directory containing the python3.lib import library (Windows).
418/// Tries `bin/py3/` first (separate package layout in Simics 7.28.0+),
419/// then falls back to `bin/` (bundled layout).
420/// On Unix this returns `lib_dir` equivalent.
421fn find_import_lib_dir(base_path: &Path) -> PathBuf {
422    let py3_dir = base_path.join("bin").join("py3");
423    if py3_dir.join("python3.lib").exists() {
424        return py3_dir;
425    }
426    // Fall back to bundled bin/ location
427    base_path.join("bin")
428}
429
430/// Find Python library directory and specific library file
431fn find_python_library(base_path: &Path) -> Result<(PathBuf, PathBuf)> {
432    // Check environment variable first for compatibility
433    if let Ok(lib_env) = env::var(PYTHON3_LDFLAGS_ENV) {
434        let lib_path = PathBuf::from(lib_env);
435        if lib_path.exists() {
436            let lib_dir = lib_path
437                .parent()
438                .ok_or_else(|| anyhow!("Library path has no parent directory"))?
439                .to_path_buf();
440            return Ok((lib_dir, lib_path));
441        }
442    }
443
444    let lib_dir = get_lib_dir(base_path, &PLATFORM_CONFIG)?;
445    find_libpython_in_dir_with_config(&lib_dir, &PLATFORM_CONFIG)
446}
447
448// ============================================================================
449// Tests
450// ============================================================================
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use std::fs;
456    use tempfile::TempDir;
457
458    // Test configs - can test BOTH platforms on ANY platform
459    const UNIX_CONFIG: PlatformConfig = PlatformConfig {
460        lib_subdir: &["sys", "lib"],
461        lib_has_version_subdir: false,
462        lib_prefix: "libpython",
463        lib_extension: ".so",
464        generic_lib_name: "libpython3.so",
465    };
466
467    const WINDOWS_CONFIG: PlatformConfig = PlatformConfig {
468        lib_subdir: &["lib"],
469        lib_has_version_subdir: true,
470        lib_prefix: "python3",
471        lib_extension: ".dll",
472        generic_lib_name: "python3.dll",
473    };
474
475    // ========================================================================
476    // Cross-platform unit tests for core logic
477    // ========================================================================
478
479    #[test]
480    fn test_is_python_library_name_unix() {
481        assert!(is_python_library_name("libpython3.9.so.1.0", &UNIX_CONFIG));
482        assert!(is_python_library_name("libpython3.so", &UNIX_CONFIG));
483        assert!(is_python_library_name("libpython3.9.so", &UNIX_CONFIG));
484        assert!(!is_python_library_name("python3.9.dll", &UNIX_CONFIG));
485        assert!(!is_python_library_name("libfoo.so", &UNIX_CONFIG));
486    }
487
488    #[test]
489    fn test_is_python_library_name_windows() {
490        assert!(is_python_library_name("python3.10.dll", &WINDOWS_CONFIG));
491        assert!(is_python_library_name("python3.dll", &WINDOWS_CONFIG));
492        assert!(!is_python_library_name("libpython3.9.so", &WINDOWS_CONFIG));
493        assert!(!is_python_library_name("python3.10.lib", &WINDOWS_CONFIG));
494        assert!(!is_python_library_name("python2.7.dll", &WINDOWS_CONFIG));
495    }
496
497    #[test]
498    fn test_get_lib_dir_unix() -> Result<()> {
499        let base = PathBuf::from("/opt/simics/linux64");
500        let lib_dir = get_lib_dir(&base, &UNIX_CONFIG)?;
501        assert_eq!(lib_dir, PathBuf::from("/opt/simics/linux64/sys/lib"));
502        Ok(())
503    }
504
505    #[test]
506    fn test_get_lib_dir_windows() -> Result<()> {
507        let temp_dir = TempDir::new()?;
508        let lib_dir = temp_dir.path().join("lib").join("python3.10");
509        fs::create_dir_all(&lib_dir)?;
510
511        let result = get_lib_dir(temp_dir.path(), &WINDOWS_CONFIG)?;
512        assert_eq!(result, lib_dir);
513        Ok(())
514    }
515
516    #[test]
517    fn test_filter_removes_generic_unix() {
518        let mut files = vec![
519            PathBuf::from("/lib/libpython3.so"),
520            PathBuf::from("/lib/libpython3.9.so.1.0"),
521        ];
522        filter_python_libraries_with_config(&mut files, &UNIX_CONFIG);
523        assert_eq!(files.len(), 1);
524        assert!(files[0].to_string_lossy().contains("libpython3.9.so.1.0"));
525    }
526
527    #[test]
528    fn test_filter_removes_generic_windows() {
529        let mut files = vec![
530            PathBuf::from("/bin/python3.dll"),
531            PathBuf::from("/bin/python3.10.dll"),
532        ];
533        filter_python_libraries_with_config(&mut files, &WINDOWS_CONFIG);
534        assert_eq!(files.len(), 1);
535        assert!(files[0].to_string_lossy().contains("python3.10.dll"));
536    }
537
538    #[test]
539    fn test_filter_prefers_versioned_so_unix() {
540        let mut files = vec![
541            PathBuf::from("/lib/libpython3.9.so"),
542            PathBuf::from("/lib/libpython3.9.so.1.0"),
543        ];
544        filter_python_libraries_with_config(&mut files, &UNIX_CONFIG);
545        assert_eq!(files.len(), 1);
546        assert!(files[0].to_string_lossy().ends_with(".so.1.0"));
547    }
548
549    #[test]
550    fn test_filter_keeps_single_file() {
551        let mut files = vec![PathBuf::from("/lib/libpython3.9.so.1.0")];
552        filter_python_libraries_with_config(&mut files, &UNIX_CONFIG);
553        assert_eq!(files.len(), 1);
554    }
555
556    // ========================================================================
557    // Integration tests with mock filesystem
558    // ========================================================================
559
560    #[test]
561    fn test_find_libpython_unix_structure() -> Result<()> {
562        let temp_dir = TempDir::new()?;
563        let lib_dir = temp_dir.path().join("sys").join("lib");
564        fs::create_dir_all(&lib_dir)?;
565        fs::write(lib_dir.join("libpython3.9.so.1.0"), "")?;
566
567        let base_path = temp_dir.path();
568        let actual_lib_dir = get_lib_dir(base_path, &UNIX_CONFIG)?;
569        let (found_dir, found_path) =
570            find_libpython_in_dir_with_config(&actual_lib_dir, &UNIX_CONFIG)?;
571
572        assert_eq!(found_dir, lib_dir);
573        assert!(found_path.to_string_lossy().contains("libpython3.9.so.1.0"));
574        Ok(())
575    }
576
577    #[test]
578    fn test_find_libpython_windows_structure() -> Result<()> {
579        let temp_dir = TempDir::new()?;
580        let bin_dir = temp_dir.path().join("lib").join("python3.10");
581        fs::create_dir_all(&bin_dir)?;
582        fs::write(bin_dir.join("python3.dll"), "")?;
583
584        let base_path = temp_dir.path();
585        let actual_lib_dir = get_lib_dir(base_path, &WINDOWS_CONFIG)?;
586        let (found_dir, found_path) =
587            find_libpython_in_dir_with_config(&actual_lib_dir, &WINDOWS_CONFIG)?;
588
589        assert_eq!(found_dir, bin_dir);
590        assert!(found_path.to_string_lossy().contains("python3.dll"));
591        Ok(())
592    }
593
594    #[test]
595    fn test_multiple_libraries_error_unix() -> Result<()> {
596        let temp_dir = TempDir::new()?;
597        let lib_dir = temp_dir.path();
598        fs::write(lib_dir.join("libpython3.9.so.1.0"), "")?;
599        fs::write(lib_dir.join("libpython3.10.so.1.0"), "")?;
600
601        let err = find_libpython_in_dir_with_config(lib_dir, &UNIX_CONFIG).unwrap_err();
602        assert!(err.to_string().contains("Multiple Python library files"));
603        Ok(())
604    }
605
606    #[test]
607    fn test_multiple_libraries_error_windows() -> Result<()> {
608        let temp_dir = TempDir::new()?;
609        let lib_dir = temp_dir.path();
610        fs::write(lib_dir.join("python3.9.dll"), "")?;
611        fs::write(lib_dir.join("python3.10.dll"), "")?;
612
613        let err = find_libpython_in_dir_with_config(lib_dir, &WINDOWS_CONFIG).unwrap_err();
614        assert!(err.to_string().contains("Multiple Python library files"));
615        Ok(())
616    }
617
618    #[test]
619    fn test_no_library_error() -> Result<()> {
620        let temp_dir = TempDir::new()?;
621        let lib_dir = temp_dir.path();
622        // Create an empty directory
623
624        let err = find_libpython_in_dir_with_config(lib_dir, &UNIX_CONFIG).unwrap_err();
625        assert!(err.to_string().contains("No Python library file found"));
626        Ok(())
627    }
628
629    #[test]
630    fn test_nonexistent_directory_error() {
631        let lib_dir = PathBuf::from("/nonexistent/path");
632        let err = find_libpython_in_dir_with_config(&lib_dir, &UNIX_CONFIG).unwrap_err();
633        assert!(err.to_string().contains("does not exist"));
634    }
635
636    // ========================================================================
637    // Full discovery tests (platform-specific due to PLATFORM_CONFIG)
638    // ========================================================================
639
640    fn create_mock_simics_structure(base_dir: &Path, python_version: &str) -> Result<()> {
641        let host_dir = base_dir.join(HOST_DIRNAME);
642        fs::create_dir_all(host_dir.join("bin"))?;
643        fs::create_dir_all(
644            host_dir
645                .join("include")
646                .join(format!("python{}", python_version)),
647        )?;
648
649        // Create mock mini-python
650        let mini_python = if cfg!(windows) {
651            "mini-python.exe"
652        } else {
653            "mini-python"
654        };
655        fs::write(host_dir.join("bin").join(mini_python), "")?;
656
657        // Create platform-specific library structure
658        #[cfg(unix)]
659        {
660            fs::create_dir_all(host_dir.join("sys").join("lib"))?;
661            fs::write(
662                host_dir
663                    .join("sys")
664                    .join("lib")
665                    .join(format!("libpython{}.so", python_version)),
666                "",
667            )?;
668        }
669
670        #[cfg(windows)]
671        {
672            fs::write(
673                host_dir
674                    .join("bin")
675                    .join(format!("python{}.dll", python_version)),
676                "",
677            )?;
678        }
679
680        Ok(())
681    }
682
683    #[test]
684    fn test_discover_traditional_structure() -> Result<()> {
685        let temp_dir = TempDir::new()?;
686        let base_path = temp_dir.path();
687
688        create_mock_simics_structure(base_path, "3.9")?;
689
690        let env = discover_python_environment_from_base(base_path)?;
691
692        assert_eq!(env.package_source, PackageSource::Bundled);
693        assert_eq!(env.version.major, 3);
694        assert_eq!(env.version.minor, 9);
695        assert!(env.mini_python.exists());
696        assert!(env.include_dir.exists());
697        assert!(env.lib_path.exists());
698
699        Ok(())
700    }
701
702    #[test]
703    fn test_version_parsing() -> Result<()> {
704        let temp_dir = TempDir::new()?;
705        let include_dir = temp_dir.path().join("python3.9.10");
706        fs::create_dir_all(&include_dir)?;
707
708        let version = PythonVersion::parse_from_include_dir(&include_dir)?;
709        assert_eq!(version.major, 3);
710        assert_eq!(version.minor, 9);
711        assert_eq!(version.patch, 10);
712
713        Ok(())
714    }
715
716    #[test]
717    fn test_multiple_python_include_dirs_error() -> Result<()> {
718        let temp_dir = TempDir::new()?;
719        let include_dir = temp_dir.path().join("include");
720        fs::create_dir_all(include_dir.join("python3.9"))?;
721        fs::create_dir_all(include_dir.join("python3.10"))?;
722
723        let err = find_python_subdir(&include_dir).unwrap_err();
724        assert!(err
725            .to_string()
726            .contains("Multiple python3.* subdirectories"));
727
728        Ok(())
729    }
730
731    #[test]
732    fn test_detect_simics_major_version_from_base() -> Result<()> {
733        let base = PathBuf::from("/opt/simics/simics-7.38.0");
734        let major = detect_simics_major_version_from_base(&base)?;
735        assert_eq!(major, 7);
736
737        Ok(())
738    }
739
740    #[test]
741    fn test_detect_simics_major_version_from_base_v6() -> Result<()> {
742        let base = PathBuf::from("/opt/simics/simics-6.0.191");
743        let major = detect_simics_major_version_from_base(&base)?;
744        assert_eq!(major, 6);
745
746        Ok(())
747    }
748
749    #[test]
750    fn test_detect_simics_major_version_from_base_invalid() {
751        let base = PathBuf::from("/opt/simics/current");
752        let err = detect_simics_major_version_from_base(&base).unwrap_err();
753        assert!(err
754            .to_string()
755            .contains("expected directory name like simics-x.x.x"));
756    }
757}