Skip to main content

rusty_stack/platform/
wsl.rs

1//! WSL2 detection, health checks, and provisioning guidance.
2//!
3//! Provides:
4//! - **WSL2 detection** — positive and negative cases, no timeout
5//!   (VAL-WIN-003, VAL-WIN-004)
6//! - **WSL2 provisioning guidance** — actionable steps when WSL2 not installed
7//!   (VAL-WIN-005)
8//! - **WSL2 health checks** — distro running, ROCm accessible, GPU nodes
9//!   (VAL-WIN-006)
10//!
11//! # Detection Strategy
12//!
13//! On Windows: invokes `wsl --list --verbose` via Command
14//! On Linux: checks `/proc/version` for "microsoft" or "WSL" indicators
15//!
16//! All detection completes within 5 seconds. No panics on missing commands.
17
18use serde::{Deserialize, Serialize};
19
20// ===========================================================================
21// Public Types
22// ===========================================================================
23
24/// Status of WSL2 on the current system.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(tag = "status", rename_all = "snake_case")]
27pub enum WslStatus {
28    /// WSL2 is not installed or not available.
29    NotInstalled,
30    /// WSL2 is available with a running distro.
31    Available {
32        /// Name of the default or detected WSL2 distribution.
33        distro: String,
34        /// WSL version (always 2 for this variant).
35        version: u8,
36    },
37    /// WSL2 detection encountered an error.
38    Error {
39        /// Error message describing what went wrong.
40        message: String,
41    },
42}
43
44/// Result of a single WSL2 health check.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct WslHealthResult {
47    /// Name of the health check performed.
48    pub check: String,
49    /// Whether the check passed.
50    pub success: bool,
51    /// Human-readable description of the result.
52    pub message: String,
53}
54
55// ===========================================================================
56// Public API
57// ===========================================================================
58
59/// Detect WSL2 availability on the current system.
60///
61/// Returns a [`WslStatus`] indicating whether WSL2 is available, not installed,
62/// or if detection encountered an error. Detection completes within 5 seconds
63/// and never panics.
64///
65/// # Platform Behavior
66///
67/// - **Windows**: Checks `wsl --list --verbose` output for version 2 distros
68/// - **Linux**: Checks `/proc/version` for WSL indicators (running inside WSL)
69///
70/// # Examples
71///
72/// ```no_run
73/// use rusty_stack::platform::wsl::detect_wsl2;
74///
75/// let status = detect_wsl2();
76/// println!("WSL2 status: {:?}", status);
77/// ```
78pub fn detect_wsl2() -> WslStatus {
79    #[cfg(target_os = "windows")]
80    {
81        detect_wsl2_windows()
82    }
83
84    #[cfg(not(target_os = "windows"))]
85    {
86        detect_wsl2_non_windows()
87    }
88}
89
90/// Generate provisioning guidance for the given WSL2 status.
91///
92/// When WSL2 is not installed, returns actionable steps including
93/// the `wsl --install` command and manual setup instructions.
94/// When WSL2 is available, returns confirmation and next steps.
95pub fn provisioning_guidance(status: &WslStatus) -> String {
96    match status {
97        WslStatus::NotInstalled => {
98            let mut guidance = String::new();
99            guidance.push_str("WSL2 is not installed on this system.\n\n");
100            guidance.push_str("To install WSL2:\n");
101            guidance.push_str("  1. Open PowerShell as Administrator\n");
102            guidance.push_str("  2. Run: wsl --install\n");
103            guidance.push_str("  3. Restart your computer when prompted\n");
104            guidance.push_str("  4. Set up a default Linux distribution (Ubuntu recommended)\n");
105            guidance.push_str("  5. Install ROCm inside WSL2 following AMD's WSL2 guide\n\n");
106            guidance.push_str("After installation, click 'Re-check' to verify WSL2 setup.");
107            guidance
108        }
109        WslStatus::Available { distro, .. } => {
110            format!(
111                "WSL2 is available with distribution '{}'.\n\n\
112                 Next steps:\n\
113                 1. Ensure ROCm is installed inside the WSL2 distribution\n\
114                 2. Verify GPU access with 'rocm-smi' inside WSL2\n\
115                 3. The installer will use WSL2 for Linux-only components",
116                distro
117            )
118        }
119        WslStatus::Error { message: msg } => {
120            format!(
121                "WSL2 detection encountered an error: {}\n\n\
122                 Troubleshooting:\n\
123                 1. Ensure Windows is updated to the latest version\n\
124                 2. Check that 'Windows Subsystem for Linux' is enabled\n\
125                 3. Try running 'wsl --install' from an Administrator PowerShell",
126                msg
127            )
128        }
129    }
130}
131
132/// Perform WSL2 health checks.
133///
134/// Returns exactly 3 independent health check results:
135/// 1. **distro_running** — Is the default WSL2 distro running?
136/// 2. **rocm_accessible** — Is ROCm accessible inside WSL2?
137/// 3. **gpu_nodes** — Are GPU device nodes present in WSL2?
138///
139/// Each check is independent — a failure in one does not affect the others.
140pub fn check_wsl_health(status: &WslStatus) -> Vec<WslHealthResult> {
141    match status {
142        WslStatus::NotInstalled => vec![
143            WslHealthResult {
144                check: "distro_running".to_string(),
145                success: false,
146                message: "WSL2 is not installed".to_string(),
147            },
148            WslHealthResult {
149                check: "rocm_accessible".to_string(),
150                success: false,
151                message: "WSL2 is not installed".to_string(),
152            },
153            WslHealthResult {
154                check: "gpu_nodes".to_string(),
155                success: false,
156                message: "WSL2 is not installed".to_string(),
157            },
158        ],
159        WslStatus::Available { distro, .. } => check_wsl_health_available(distro),
160        WslStatus::Error { message: msg } => vec![
161            WslHealthResult {
162                check: "distro_running".to_string(),
163                success: false,
164                message: format!("Detection error: {}", msg),
165            },
166            WslHealthResult {
167                check: "rocm_accessible".to_string(),
168                success: false,
169                message: format!("Detection error: {}", msg),
170            },
171            WslHealthResult {
172                check: "gpu_nodes".to_string(),
173                success: false,
174                message: format!("Detection error: {}", msg),
175            },
176        ],
177    }
178}
179
180// ===========================================================================
181// Private Implementation
182// ===========================================================================
183
184/// WSL2 detection on Windows: invoke `wsl --list --verbose`.
185#[cfg(target_os = "windows")]
186fn detect_wsl2_windows() -> WslStatus {
187    use std::process::Command;
188
189    let output = match Command::new("wsl").args(["--list", "--verbose"]).output() {
190        Ok(o) => o,
191        Err(e) => {
192            return WslStatus::Error {
193                message: format!("Failed to execute wsl: {}", e),
194            }
195        }
196    };
197
198    if !output.status.success() {
199        return WslStatus::NotInstalled;
200    }
201
202    let stdout = String::from_utf8_lossy(&output.stdout);
203    parse_wsl_list_output(&stdout)
204}
205
206/// Parse `wsl --list --verbose` output to find a WSL2 distro.
207#[cfg(any(target_os = "windows", test))]
208fn parse_wsl_list_output(output: &str) -> WslStatus {
209    for line in output.lines().skip(1) {
210        // Skip header line
211        let line = line.trim();
212        if line.is_empty() {
213            continue;
214        }
215
216        // Parse: NAME, STATE, VERSION
217        // The default distro is marked with *
218        // * can be: adjacent to name (*Ubuntu) or separated (* Ubuntu)
219        let parts: Vec<&str> = line.split_whitespace().collect();
220        if parts.len() < 3 {
221            continue;
222        }
223
224        let version_str = parts[parts.len() - 1]; // VERSION is the last column
225        let state_str = parts[parts.len() - 2]; // STATE is second-to-last
226
227        // Determine the name: could start at index 0 or 1 depending on standalone *
228        let name = if parts[0] == "*" {
229            // Standalone asterisk: name is parts[1]
230            if parts.len() < 4 {
231                continue;
232            }
233            parts[1]
234        } else if parts[0].starts_with('*') {
235            // Asterisk prefixed to name: *Ubuntu-22.04
236            parts[0].trim_start_matches('*')
237        } else {
238            parts[0]
239        };
240
241        // Validate that state_str looks like a state (Running, Stopped, etc.)
242        // and version_str looks like a number
243        let _ = state_str; // We just need name and version
244
245        if let Ok(version) = version_str.parse::<u8>() {
246            if version == 2 && !name.is_empty() {
247                return WslStatus::Available {
248                    distro: name.to_string(),
249                    version: 2,
250                };
251            }
252        }
253    }
254
255    // WSL is installed but no v2 distro found
256    WslStatus::NotInstalled
257}
258
259/// WSL2 detection on non-Windows (Linux/macOS).
260///
261/// On Linux, checks if we're running inside WSL by examining `/proc/version`.
262#[cfg(not(target_os = "windows"))]
263fn detect_wsl2_non_windows() -> WslStatus {
264    // Check if running inside WSL
265    if is_running_in_wsl() {
266        let distro = detect_wsl_distro_name();
267        return WslStatus::Available {
268            distro: distro.unwrap_or_else(|| "unknown".to_string()),
269            version: 2,
270        };
271    }
272
273    WslStatus::NotInstalled
274}
275
276/// Check if the current Linux system is running inside WSL.
277#[cfg(not(target_os = "windows"))]
278fn is_running_in_wsl() -> bool {
279    // Check /proc/version for Microsoft/WSL indicators
280    if let Ok(version) = std::fs::read_to_string("/proc/version") {
281        let version_lower = version.to_lowercase();
282        return version_lower.contains("microsoft") || version_lower.contains("wsl");
283    }
284    false
285}
286
287/// Detect the WSL distribution name from /etc/os-release or WSL_DISTRO_NAME env var.
288#[cfg(not(target_os = "windows"))]
289fn detect_wsl_distro_name() -> Option<String> {
290    // First check WSL_DISTRO_NAME environment variable
291    if let Ok(name) = std::env::var("WSL_DISTRO_NAME") {
292        if !name.is_empty() {
293            return Some(name);
294        }
295    }
296
297    // Fall back to /etc/os-release NAME field
298    if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
299        for line in content.lines() {
300            if let Some(name) = line.strip_prefix("NAME=") {
301                let name = name.trim().trim_matches('"').trim_matches('\'');
302                if !name.is_empty() {
303                    return Some(name.to_string());
304                }
305            }
306        }
307    }
308
309    None
310}
311
312/// Perform health checks when WSL2 is available.
313fn check_wsl_health_available(distro: &str) -> Vec<WslHealthResult> {
314    let distro_result = check_distro_running(distro);
315    let rocm_result = check_rocm_accessible();
316    let gpu_result = check_gpu_nodes();
317
318    vec![distro_result, rocm_result, gpu_result]
319}
320
321/// Check if the default WSL2 distro is running.
322fn check_distro_running(distro: &str) -> WslHealthResult {
323    #[cfg(target_os = "windows")]
324    {
325        use std::process::Command;
326
327        let output = match Command::new("wsl").args(["--list", "--running"]).output() {
328            Ok(o) => o,
329            Err(e) => {
330                return WslHealthResult {
331                    check: "distro_running".to_string(),
332                    success: false,
333                    message: format!("Failed to check running distros: {}", e),
334                }
335            }
336        };
337
338        let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
339        let running = stdout.contains(&distro.to_lowercase());
340
341        WslHealthResult {
342            check: "distro_running".to_string(),
343            success: running,
344            message: if running {
345                format!("{} is running", distro)
346            } else {
347                format!("{} is not currently running", distro)
348            },
349        }
350    }
351
352    #[cfg(not(target_os = "windows"))]
353    {
354        // On Linux (including WSL), the distro is always "running" if we're executing code
355        WslHealthResult {
356            check: "distro_running".to_string(),
357            success: true,
358            message: format!("{} is running (native execution)", distro),
359        }
360    }
361}
362
363/// Check if ROCm is accessible.
364fn check_rocm_accessible() -> WslHealthResult {
365    #[cfg(target_os = "windows")]
366    {
367        use std::process::Command;
368
369        let output = match Command::new("wsl")
370            .args(["--", "rocm-smi", "--version"])
371            .output()
372        {
373            Ok(o) => o,
374            Err(e) => {
375                return WslHealthResult {
376                    check: "rocm_accessible".to_string(),
377                    success: false,
378                    message: format!("Failed to check ROCm in WSL: {}", e),
379                }
380            }
381        };
382
383        let success = output.status.success();
384        WslHealthResult {
385            check: "rocm_accessible".to_string(),
386            success,
387            message: if success {
388                "ROCm is accessible inside WSL2".to_string()
389            } else {
390                "ROCm is not accessible inside WSL2".to_string()
391            },
392        }
393    }
394
395    #[cfg(not(target_os = "windows"))]
396    {
397        // On Linux, check if rocm-smi is available
398        let rocm_accessible = std::path::Path::new("/opt/rocm/bin/rocm-smi").exists()
399            || std::process::Command::new("rocm-smi")
400                .arg("--version")
401                .output()
402                .map(|o| o.status.success())
403                .unwrap_or(false);
404
405        WslHealthResult {
406            check: "rocm_accessible".to_string(),
407            success: rocm_accessible,
408            message: if rocm_accessible {
409                "ROCm is accessible".to_string()
410            } else {
411                "ROCm is not accessible".to_string()
412            },
413        }
414    }
415}
416
417/// Check if GPU device nodes exist.
418fn check_gpu_nodes() -> WslHealthResult {
419    #[cfg(target_os = "windows")]
420    {
421        use std::process::Command;
422
423        let output = match Command::new("wsl").args(["--", "ls", "/dev/kfd"]).output() {
424            Ok(o) => o,
425            Err(e) => {
426                return WslHealthResult {
427                    check: "gpu_nodes".to_string(),
428                    success: false,
429                    message: format!("Failed to check GPU nodes in WSL: {}", e),
430                }
431            }
432        };
433
434        let success = output.status.success();
435        WslHealthResult {
436            check: "gpu_nodes".to_string(),
437            success,
438            message: if success {
439                "GPU device nodes are present in WSL2".to_string()
440            } else {
441                "GPU device nodes are not present in WSL2".to_string()
442            },
443        }
444    }
445
446    #[cfg(not(target_os = "windows"))]
447    {
448        // On Linux, check /dev/kfd and /dev/dri
449        let kfd_exists = std::path::Path::new("/dev/kfd").exists();
450        let dri_exists = std::path::Path::new("/dev/dri").exists();
451        let gpu_nodes_present = kfd_exists || dri_exists;
452
453        WslHealthResult {
454            check: "gpu_nodes".to_string(),
455            success: gpu_nodes_present,
456            message: if gpu_nodes_present {
457                "GPU device nodes are present".to_string()
458            } else {
459                "GPU device nodes are not present".to_string()
460            },
461        }
462    }
463}
464
465// ===========================================================================
466// Tests
467// ===========================================================================
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    // ---- WSL2 Detection ----
474
475    #[test]
476    fn test_detect_wsl2_returns_valid_status() {
477        let status = detect_wsl2();
478        // Should be one of the valid variants
479        match &status {
480            WslStatus::NotInstalled => {}
481            WslStatus::Available { distro, version } => {
482                assert!(!distro.is_empty());
483                assert_eq!(*version, 2);
484            }
485            WslStatus::Error { message: msg } => {
486                assert!(!msg.is_empty());
487            }
488        }
489    }
490
491    #[test]
492    fn test_detect_wsl2_completes_quickly() {
493        let start = std::time::Instant::now();
494        let _ = detect_wsl2();
495        let elapsed = start.elapsed();
496        assert!(elapsed.as_secs() < 5, "Detection took {:?}", elapsed);
497    }
498
499    // ---- Provisioning Guidance ----
500
501    #[test]
502    fn test_guidance_not_installed() {
503        let g = provisioning_guidance(&WslStatus::NotInstalled);
504        assert!(g.to_lowercase().contains("wsl"));
505        assert!(g.to_lowercase().contains("install"));
506    }
507
508    #[test]
509    fn test_guidance_available() {
510        let s = WslStatus::Available {
511            distro: "Ubuntu".to_string(),
512            version: 2,
513        };
514        let g = provisioning_guidance(&s);
515        assert!(g.contains("Ubuntu"));
516    }
517
518    #[test]
519    fn test_guidance_error() {
520        let s = WslStatus::Error {
521            message: "test".to_string(),
522        };
523        let g = provisioning_guidance(&s);
524        assert!(!g.is_empty());
525    }
526
527    // ---- Health Checks ----
528
529    #[test]
530    fn test_health_checks_not_installed_count() {
531        let results = check_wsl_health(&WslStatus::NotInstalled);
532        assert_eq!(results.len(), 3);
533    }
534
535    #[test]
536    fn test_health_checks_not_installed_all_fail() {
537        let results = check_wsl_health(&WslStatus::NotInstalled);
538        for r in &results {
539            assert!(!r.success, "{} should fail when not installed", r.check);
540        }
541    }
542
543    #[test]
544    fn test_health_checks_independent() {
545        let results = check_wsl_health(&WslStatus::NotInstalled);
546        let names: std::collections::HashSet<&str> =
547            results.iter().map(|r| r.check.as_str()).collect();
548        assert_eq!(names.len(), 3);
549    }
550
551    #[test]
552    fn test_health_check_names() {
553        let results = check_wsl_health(&WslStatus::NotInstalled);
554        let names: Vec<&str> = results.iter().map(|r| r.check.as_str()).collect();
555        assert!(names.contains(&"distro_running"));
556        assert!(names.contains(&"rocm_accessible"));
557        assert!(names.contains(&"gpu_nodes"));
558    }
559
560    // ---- Serde Roundtrips ----
561
562    #[test]
563    fn test_wsl_status_serde_not_installed() {
564        let s = WslStatus::NotInstalled;
565        let json = serde_json::to_string(&s).unwrap();
566        let back: WslStatus = serde_json::from_str(&json).unwrap();
567        assert_eq!(s, back);
568    }
569
570    #[test]
571    fn test_wsl_status_serde_available() {
572        let s = WslStatus::Available {
573            distro: "Ubuntu-22.04".to_string(),
574            version: 2,
575        };
576        let json = serde_json::to_string(&s).unwrap();
577        let back: WslStatus = serde_json::from_str(&json).unwrap();
578        assert_eq!(s, back);
579    }
580
581    #[test]
582    fn test_wsl_status_serde_error() {
583        let s = WslStatus::Error {
584            message: "test error".to_string(),
585        };
586        let json = serde_json::to_string(&s).unwrap();
587        let back: WslStatus = serde_json::from_str(&json).unwrap();
588        assert_eq!(s, back);
589    }
590
591    #[test]
592    fn test_wsl_health_result_serde() {
593        let r = WslHealthResult {
594            check: "distro_running".to_string(),
595            success: true,
596            message: "Running".to_string(),
597        };
598        let json = serde_json::to_string(&r).unwrap();
599        let back: WslHealthResult = serde_json::from_str(&json).unwrap();
600        assert_eq!(r, back);
601    }
602
603    // ---- Parse WSL Output ----
604
605    #[test]
606    fn test_parse_wsl_list_with_v2_distro() {
607        let output = "  NAME                   STATE           VERSION\n\
608                       * Ubuntu-22.04           Running         2\n\
609                         Debian                 Stopped         1";
610        let status = parse_wsl_list_output(output);
611        match status {
612            WslStatus::Available { distro, version } => {
613                assert_eq!(distro, "Ubuntu-22.04");
614                assert_eq!(version, 2);
615            }
616            _ => panic!("Expected Available, got {:?}", status),
617        }
618    }
619
620    #[test]
621    fn test_parse_wsl_list_no_v2_distro() {
622        let output = "  NAME                   STATE           VERSION\n\
623                         Debian                 Stopped         1";
624        let status = parse_wsl_list_output(output);
625        assert_eq!(status, WslStatus::NotInstalled);
626    }
627
628    #[test]
629    fn test_parse_wsl_list_empty() {
630        let output = "";
631        let status = parse_wsl_list_output(output);
632        assert_eq!(status, WslStatus::NotInstalled);
633    }
634
635    #[test]
636    #[cfg(not(target_os = "windows"))]
637    fn test_is_running_in_wsl() {
638        // This test just verifies the function doesn't panic
639        let _ = is_running_in_wsl();
640    }
641}