Skip to main content

rusty_stack/installers/components/
mpi4py.rs

1//! MPI4Py installer — ports `scripts/install_mpi4py.sh`.
2//!
3//! Detects system MPI implementation, constructs pip install with correct
4//! MPICC/MPICXX environment variables.
5//!
6//! # Validation Assertion
7//!
8//! - **VAL-INSTALL-005**: MPI4Py installer detects system MPI
9
10use crate::installers::common::{command_exists, DistroFacade};
11use std::fmt;
12use std::path::PathBuf;
13
14// ===========================================================================
15// Types
16// ===========================================================================
17
18/// Detected MPI implementation.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum MpiImplementation {
21    /// OpenMPI (most common).
22    OpenMPI,
23    /// MPICH.
24    MPICH,
25    /// Intel MPI.
26    IntelMPI,
27    /// No MPI detected.
28    None,
29}
30
31impl fmt::Display for MpiImplementation {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            MpiImplementation::OpenMPI => write!(f, "OpenMPI"),
35            MpiImplementation::MPICH => write!(f, "MPICH"),
36            MpiImplementation::IntelMPI => write!(f, "IntelMPI"),
37            MpiImplementation::None => write!(f, "None"),
38        }
39    }
40}
41
42/// Configuration for the MPI4Py installer.
43#[derive(Debug, Clone)]
44pub struct Mpi4PyConfig {
45    /// Python binary to use.
46    pub python_bin: String,
47    /// Whether to force reinstall.
48    pub force_reinstall: bool,
49    /// Whether to run in dry-run mode.
50    pub dry_run: bool,
51    /// Whether ROCm is enabled.
52    pub rocm_enabled: bool,
53}
54
55impl Default for Mpi4PyConfig {
56    fn default() -> Self {
57        Self {
58            python_bin: "python3".to_string(),
59            force_reinstall: false,
60            dry_run: false,
61            rocm_enabled: true,
62        }
63    }
64}
65
66/// A constructed shell command.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ShellCommand {
69    /// The program to run.
70    pub program: String,
71    /// Arguments to pass.
72    pub args: Vec<String>,
73    /// Environment variables to set.
74    pub env: Vec<(String, String)>,
75}
76
77impl ShellCommand {
78    /// Format as a shell command string.
79    pub fn to_command_string(&self) -> String {
80        let env_prefix = self
81            .env
82            .iter()
83            .map(|(k, v)| format!("{k}={v}"))
84            .collect::<Vec<_>>()
85            .join(" ");
86        let cmd = if self.args.is_empty() {
87            self.program.clone()
88        } else {
89            format!("{} {}", self.program, self.args.join(" "))
90        };
91        if env_prefix.is_empty() {
92            cmd
93        } else {
94            format!("{env_prefix} {cmd}")
95        }
96    }
97}
98
99/// The MPI4Py installer.
100pub struct Mpi4PyInstaller {
101    config: Mpi4PyConfig,
102}
103
104impl Mpi4PyInstaller {
105    /// Create a new MPI4Py installer with the given config.
106    pub fn new(config: Mpi4PyConfig) -> Self {
107        Self { config }
108    }
109
110    /// Create with default config.
111    pub fn with_defaults() -> Self {
112        Self::new(Mpi4PyConfig::default())
113    }
114
115    // -----------------------------------------------------------------------
116    // MPI detection (VAL-INSTALL-005)
117    // -----------------------------------------------------------------------
118
119    /// Detect the MPI implementation on the system.
120    ///
121    /// Checks for mpirun/mpiexec and identifies the implementation.
122    pub fn detect_mpi(&self) -> MpiImplementation {
123        if command_exists("mpirun") || command_exists("mpiexec") {
124            // Try to identify which MPI
125            if self.is_openmpi() {
126                MpiImplementation::OpenMPI
127            } else if self.is_mpich() {
128                MpiImplementation::MPICH
129            } else {
130                // Default to OpenMPI if we can't determine
131                MpiImplementation::OpenMPI
132            }
133        } else {
134            MpiImplementation::None
135        }
136    }
137
138    /// Check if the detected MPI is OpenMPI.
139    fn is_openmpi(&self) -> bool {
140        // Check for ompi_info which is OpenMPI-specific
141        command_exists("ompi_info")
142            || std::path::Path::new("/usr/lib/x86_64-linux-gnu/openmpi").exists()
143            || std::path::Path::new("/usr/lib64/openmpi").exists()
144    }
145
146    /// Check if the detected MPI is MPICH.
147    fn is_mpich(&self) -> bool {
148        command_exists("mpichversion")
149            || std::path::Path::new("/usr/lib/x86_64-linux-gnu/mpich").exists()
150    }
151
152    /// Detect the MPI installation path.
153    ///
154    /// Checks common paths in order:
155    /// 1. /usr/lib64/openmpi
156    /// 2. /opt/openmpi
157    /// 3. /usr/lib/openmpi
158    /// 4. Derived from mpirun location
159    pub fn detect_mpi_path(&self) -> PathBuf {
160        let candidates = [
161            "/usr/lib64/openmpi",
162            "/opt/openmpi",
163            "/usr/local/openmpi",
164            "/usr/lib/openmpi",
165        ];
166
167        for path_str in &candidates {
168            let path = PathBuf::from(path_str);
169            if path.join("bin/mpirun").exists() || path.join("bin").exists() {
170                return path;
171            }
172        }
173
174        // Try to derive from mpirun location
175        if let Ok(output) = std::process::Command::new("which").arg("mpirun").output() {
176            if output.status.success() {
177                let mpirun = String::from_utf8_lossy(&output.stdout).trim().to_string();
178                if !mpirun.is_empty() {
179                    if let Some(parent) = PathBuf::from(&mpirun).parent() {
180                        if let Some(grandparent) = parent.parent() {
181                            return grandparent.to_path_buf();
182                        }
183                    }
184                }
185            }
186        }
187
188        PathBuf::from("/usr/lib64/openmpi")
189    }
190
191    // -----------------------------------------------------------------------
192    // System MPI package installation
193    // -----------------------------------------------------------------------
194
195    /// Construct the system package install command for MPI.
196    ///
197    /// Returns the correct command based on distro family:
198    /// - apt: `sudo apt-get install -y libopenmpi-dev openmpi-bin`
199    /// - dnf: `sudo dnf install -y openmpi openmpi-devel`
200    /// - pacman: `sudo pacman -S --noconfirm openmpi`
201    /// - zypper: `sudo zypper install -y openmpi openmpi-devel`
202    pub fn build_system_mpi_install_command(&self, distro: &DistroFacade) -> ShellCommand {
203        if distro.uses_apt() {
204            ShellCommand {
205                program: "sudo".to_string(),
206                args: vec![
207                    "apt-get".to_string(),
208                    "update".to_string(),
209                    "&&".to_string(),
210                    "sudo".to_string(),
211                    "apt-get".to_string(),
212                    "install".to_string(),
213                    "-y".to_string(),
214                    "libopenmpi-dev".to_string(),
215                    "openmpi-bin".to_string(),
216                ],
217                env: vec![],
218            }
219        } else if distro.uses_dnf() {
220            ShellCommand {
221                program: "sudo".to_string(),
222                args: vec![
223                    "dnf".to_string(),
224                    "install".to_string(),
225                    "-y".to_string(),
226                    "openmpi".to_string(),
227                    "openmpi-devel".to_string(),
228                ],
229                env: vec![],
230            }
231        } else if distro.uses_yum() {
232            ShellCommand {
233                program: "sudo".to_string(),
234                args: vec![
235                    "yum".to_string(),
236                    "install".to_string(),
237                    "-y".to_string(),
238                    "openmpi".to_string(),
239                    "openmpi-devel".to_string(),
240                ],
241                env: vec![],
242            }
243        } else if distro.uses_pacman() {
244            ShellCommand {
245                program: "sudo".to_string(),
246                args: vec![
247                    "pacman".to_string(),
248                    "-S".to_string(),
249                    "--noconfirm".to_string(),
250                    "openmpi".to_string(),
251                ],
252                env: vec![],
253            }
254        } else if distro.uses_zypper() {
255            ShellCommand {
256                program: "sudo".to_string(),
257                args: vec![
258                    "zypper".to_string(),
259                    "install".to_string(),
260                    "-y".to_string(),
261                    "openmpi".to_string(),
262                    "openmpi-devel".to_string(),
263                ],
264                env: vec![],
265            }
266        } else {
267            ShellCommand {
268                program: "sudo".to_string(),
269                args: vec![
270                    "apt-get".to_string(),
271                    "update".to_string(),
272                    "&&".to_string(),
273                    "sudo".to_string(),
274                    "apt-get".to_string(),
275                    "install".to_string(),
276                    "-y".to_string(),
277                    "libopenmpi-dev".to_string(),
278                    "openmpi-bin".to_string(),
279                ],
280                env: vec![],
281            }
282        }
283    }
284
285    // -----------------------------------------------------------------------
286    // mpi4py pip install command
287    // -----------------------------------------------------------------------
288
289    /// Construct the pip install command for mpi4py with correct env vars.
290    ///
291    /// Sets MPICC and MPICXX environment variables pointing to the MPI
292    /// installation's compiler wrappers.
293    pub fn build_pip_install_command(&self, mpi_path: &std::path::Path) -> ShellCommand {
294        let mpicc = mpi_path.join("bin/mpicc");
295        let mpicxx = mpi_path.join("bin/mpicxx");
296
297        let env = vec![
298            ("MPICC".to_string(), mpicc.to_string_lossy().to_string()),
299            ("MPICXX".to_string(), mpicxx.to_string_lossy().to_string()),
300            (
301                "PATH".to_string(),
302                format!(
303                    "{}:{}",
304                    mpi_path.join("bin").to_string_lossy(),
305                    std::env::var("PATH").unwrap_or_default()
306                ),
307            ),
308            (
309                "LD_LIBRARY_PATH".to_string(),
310                format!(
311                    "{}:{}",
312                    mpi_path.join("lib").to_string_lossy(),
313                    std::env::var("LD_LIBRARY_PATH").unwrap_or_default()
314                ),
315            ),
316        ];
317
318        let mut args = vec![
319            "-m".to_string(),
320            "pip".to_string(),
321            "install".to_string(),
322            "--break-system-packages".to_string(),
323        ];
324        if self.config.force_reinstall {
325            args.push("--force-reinstall".to_string());
326        }
327        args.push("mpi4py".to_string());
328
329        ShellCommand {
330            program: self.config.python_bin.clone(),
331            args,
332            env,
333        }
334    }
335}
336
337// ===========================================================================
338// Tests
339// ===========================================================================
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::platform::detection::{DistroFamily, DistroInfo, PackageManager};
345
346    fn ubuntu_distro() -> DistroFacade {
347        DistroFacade::from_info(DistroInfo {
348            id: "ubuntu".to_string(),
349            name: "Ubuntu 24.04".to_string(),
350            version: "24.04".to_string(),
351            codename: "noble".to_string(),
352            family: DistroFamily::Debian,
353            pkg_manager: PackageManager::Apt,
354            id_like: "debian".to_string(),
355        })
356    }
357
358    fn fedora_distro() -> DistroFacade {
359        DistroFacade::from_info(DistroInfo {
360            id: "fedora".to_string(),
361            name: "Fedora 41".to_string(),
362            version: "41".to_string(),
363            codename: String::new(),
364            family: DistroFamily::Rhel,
365            pkg_manager: PackageManager::Dnf,
366            id_like: "fedora".to_string(),
367        })
368    }
369
370    fn arch_distro() -> DistroFacade {
371        DistroFacade::from_info(DistroInfo {
372            id: "arch".to_string(),
373            name: "Arch Linux".to_string(),
374            version: "rolling".to_string(),
375            codename: String::new(),
376            family: DistroFamily::Arch,
377            pkg_manager: PackageManager::Pacman,
378            id_like: "arch".to_string(),
379        })
380    }
381
382    // --- VAL-INSTALL-005: MPI4Py installer detects system MPI ---
383
384    #[test]
385    fn test_detect_mpi() {
386        let installer = Mpi4PyInstaller::with_defaults();
387        // Just verify it doesn't panic
388        let mpi = installer.detect_mpi();
389        // On this system, it may or may not have MPI
390        assert!(matches!(
391            mpi,
392            MpiImplementation::OpenMPI | MpiImplementation::MPICH | MpiImplementation::None
393        ));
394    }
395
396    #[test]
397    fn test_detect_mpi_path() {
398        let installer = Mpi4PyInstaller::with_defaults();
399        let path = installer.detect_mpi_path();
400        // Should return a path, even if it's the default
401        assert!(!path.to_string_lossy().is_empty());
402    }
403
404    #[test]
405    fn test_system_mpi_install_apt() {
406        let installer = Mpi4PyInstaller::with_defaults();
407        let distro = ubuntu_distro();
408        let cmd = installer.build_system_mpi_install_command(&distro);
409        assert!(cmd.args.contains(&"libopenmpi-dev".to_string()));
410        assert!(cmd.args.contains(&"openmpi-bin".to_string()));
411    }
412
413    #[test]
414    fn test_system_mpi_install_dnf() {
415        let installer = Mpi4PyInstaller::with_defaults();
416        let distro = fedora_distro();
417        let cmd = installer.build_system_mpi_install_command(&distro);
418        assert!(cmd.args.contains(&"openmpi".to_string()));
419        assert!(cmd.args.contains(&"openmpi-devel".to_string()));
420    }
421
422    #[test]
423    fn test_system_mpi_install_pacman() {
424        let installer = Mpi4PyInstaller::with_defaults();
425        let distro = arch_distro();
426        let cmd = installer.build_system_mpi_install_command(&distro);
427        assert!(cmd.args.contains(&"openmpi".to_string()));
428        assert!(cmd.args.contains(&"--noconfirm".to_string()));
429    }
430
431    #[test]
432    fn test_pip_install_command() {
433        let installer = Mpi4PyInstaller::with_defaults();
434        let mpi_path = PathBuf::from("/usr/lib64/openmpi");
435        let cmd = installer.build_pip_install_command(&mpi_path);
436
437        assert_eq!(cmd.program, "python3");
438        assert!(cmd.args.contains(&"mpi4py".to_string()));
439        assert!(cmd.args.contains(&"--break-system-packages".to_string()));
440
441        // Verify MPICC/MPICXX env vars
442        assert!(cmd
443            .env
444            .iter()
445            .any(|(k, v)| k == "MPICC" && v.contains("mpicc")));
446        assert!(cmd
447            .env
448            .iter()
449            .any(|(k, v)| k == "MPICXX" && v.contains("mpicxx")));
450        assert!(cmd
451            .env
452            .iter()
453            .any(|(k, v)| k == "PATH" && v.contains("openmpi/bin")));
454    }
455
456    #[test]
457    fn test_pip_install_command_force_reinstall() {
458        let installer = Mpi4PyInstaller::new(Mpi4PyConfig {
459            force_reinstall: true,
460            ..Default::default()
461        });
462        let mpi_path = PathBuf::from("/usr/lib64/openmpi");
463        let cmd = installer.build_pip_install_command(&mpi_path);
464        assert!(cmd.args.contains(&"--force-reinstall".to_string()));
465    }
466
467    #[test]
468    fn test_mpi_implementation_display() {
469        assert_eq!(format!("{}", MpiImplementation::OpenMPI), "OpenMPI");
470        assert_eq!(format!("{}", MpiImplementation::MPICH), "MPICH");
471        assert_eq!(format!("{}", MpiImplementation::None), "None");
472    }
473}