Skip to main content

nex_pkg/
discover.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{Context, Result};
5
6/// The operating system nex is running on.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Platform {
9    Darwin,
10    Linux,
11}
12
13/// Detected Linux desktop environment (if any).
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[allow(dead_code)]
16pub enum DesktopEnvironment {
17    Gnome,
18    Kde,
19    Cosmic,
20    Other,
21    None,
22}
23
24impl std::fmt::Display for Platform {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Platform::Darwin => write!(f, "macOS"),
28            Platform::Linux => write!(f, "Linux"),
29        }
30    }
31}
32
33impl std::fmt::Display for DesktopEnvironment {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            DesktopEnvironment::Gnome => write!(f, "GNOME"),
37            DesktopEnvironment::Kde => write!(f, "KDE Plasma"),
38            DesktopEnvironment::Cosmic => write!(f, "COSMIC"),
39            DesktopEnvironment::Other => write!(f, "other"),
40            DesktopEnvironment::None => write!(f, "none"),
41        }
42    }
43}
44
45/// Detect the current platform at runtime (not compile-time).
46pub fn detect_platform() -> Platform {
47    if let Ok(platform) = std::env::var("NEX_TEST_PLATFORM") {
48        if std::env::var_os("NEX_TESTING").is_some() {
49            return match platform.as_str() {
50                "darwin" | "macos" => Platform::Darwin,
51                "linux" | "nixos" => Platform::Linux,
52                _ => Platform::Linux,
53            };
54        }
55    }
56
57    if runtime_os() == "darwin" {
58        Platform::Darwin
59    } else {
60        Platform::Linux
61    }
62}
63
64/// Detect the Linux desktop environment from standard env vars.
65#[allow(dead_code)]
66pub fn detect_desktop_environment() -> DesktopEnvironment {
67    if detect_platform() == Platform::Darwin {
68        return DesktopEnvironment::None;
69    }
70
71    // XDG_CURRENT_DESKTOP is the standard signal (set by display managers / session)
72    if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
73        let lower = desktop.to_lowercase();
74        if lower.contains("gnome") {
75            return DesktopEnvironment::Gnome;
76        }
77        if lower.contains("kde") || lower.contains("plasma") {
78            return DesktopEnvironment::Kde;
79        }
80        if lower.contains("cosmic") {
81            return DesktopEnvironment::Cosmic;
82        }
83        return DesktopEnvironment::Other;
84    }
85
86    // Fallback: DESKTOP_SESSION
87    if let Ok(session) = std::env::var("DESKTOP_SESSION") {
88        let lower = session.to_lowercase();
89        if lower.contains("gnome") {
90            return DesktopEnvironment::Gnome;
91        }
92        if lower.contains("plasma") || lower.contains("kde") {
93            return DesktopEnvironment::Kde;
94        }
95        if lower.contains("cosmic") {
96            return DesktopEnvironment::Cosmic;
97        }
98        return DesktopEnvironment::Other;
99    }
100
101    // Fallback: check for running processes
102    if Command::new("pgrep")
103        .arg("gnome-shell")
104        .output()
105        .map(|o| o.status.success())
106        .unwrap_or(false)
107    {
108        return DesktopEnvironment::Gnome;
109    }
110    if Command::new("pgrep")
111        .arg("plasmashell")
112        .output()
113        .map(|o| o.status.success())
114        .unwrap_or(false)
115    {
116        return DesktopEnvironment::Kde;
117    }
118    if Command::new("pgrep")
119        .arg("cosmic-comp")
120        .output()
121        .map(|o| o.status.success())
122        .unwrap_or(false)
123    {
124        return DesktopEnvironment::Cosmic;
125    }
126
127    DesktopEnvironment::None
128}
129
130/// Check if the system is running NixOS (has /etc/NIXOS marker).
131#[allow(dead_code)]
132pub fn is_nixos() -> bool {
133    Path::new("/etc/NIXOS").exists()
134}
135
136/// Auto-detect the nix config repo by walking up from CWD, then checking well-known paths.
137/// Finds both nix-darwin (macOS) and NixOS repos.
138pub fn find_repo() -> Result<PathBuf> {
139    // 1. Walk up from CWD looking for flake.nix with nix system configurations
140    if let Ok(cwd) = std::env::current_dir() {
141        let mut dir = cwd.as_path();
142        loop {
143            let flake = dir.join("flake.nix");
144            if flake.exists() && is_nex_flake(&flake) {
145                return Ok(dir.to_path_buf());
146            }
147            match dir.parent() {
148                Some(parent) => dir = parent,
149                None => break,
150            }
151        }
152    }
153
154    // 2. Check well-known paths
155    if let Some(home) = dirs::home_dir() {
156        let candidates = [
157            home.join("workspace/black-meridian/styrene-lab/macos-nix"),
158            home.join("macos-nix"),
159            home.join("nix-config"),
160            home.join(".config/nix-darwin"),
161            home.join(".config/nixos"),
162            // Legacy: NixOS configs were dropped in /etc/nixos by older polymerize
163            // runs and the upstream nixos-install installer. nex relocate moves
164            // these into ~/nix-config; the path stays here for back-compat.
165            PathBuf::from("/etc/nixos"),
166        ];
167        for path in &candidates {
168            let flake = path.join("flake.nix");
169            if flake.exists() && is_nex_flake(&flake) {
170                return Ok(path.clone());
171            }
172        }
173    }
174
175    anyhow::bail!("could not find nix config repo (nix-darwin or NixOS)")
176}
177
178/// Check if a flake.nix contains darwinConfigurations or nixosConfigurations.
179fn is_nex_flake(path: &Path) -> bool {
180    std::fs::read_to_string(path)
181        .map(|content| {
182            content.contains("darwinConfigurations") || content.contains("nixosConfigurations")
183        })
184        .unwrap_or(false)
185}
186
187/// Auto-detect the hostname. Uses scutil on macOS, /etc/hostname or hostname(1) on Linux.
188pub fn hostname() -> Result<String> {
189    match detect_platform() {
190        Platform::Darwin => {
191            let output = Command::new("scutil")
192                .args(["--get", "LocalHostName"])
193                .output()
194                .context("failed to run scutil --get LocalHostName")?;
195
196            if !output.status.success() {
197                anyhow::bail!("scutil --get LocalHostName failed");
198            }
199
200            let name = String::from_utf8(output.stdout)
201                .context("hostname is not valid UTF-8")?
202                .trim()
203                .to_string();
204
205            Ok(name)
206        }
207        Platform::Linux => {
208            // Try /etc/hostname first (most reliable on NixOS)
209            if let Ok(name) = std::fs::read_to_string("/etc/hostname") {
210                let trimmed = name.trim().to_string();
211                if !trimmed.is_empty() {
212                    return Ok(trimmed);
213                }
214            }
215
216            // Fallback to hostname command
217            let output = Command::new("hostname")
218                .output()
219                .context("failed to run hostname")?;
220
221            if !output.status.success() {
222                anyhow::bail!("hostname command failed");
223            }
224
225            let name = String::from_utf8(output.stdout)
226                .context("hostname is not valid UTF-8")?
227                .trim()
228                .to_string();
229
230            Ok(name)
231        }
232    }
233}
234
235/// Detect the nix system string for this machine.
236/// Uses runtime detection so cross-compiled binaries report the correct target.
237pub fn detect_system() -> &'static str {
238    let os = runtime_os();
239    let arch = runtime_arch();
240    match (arch, os) {
241        ("x86_64", "darwin") => "x86_64-darwin",
242        ("aarch64", "darwin") => "aarch64-darwin",
243        ("x86_64", "linux") => "x86_64-linux",
244        ("aarch64", "linux") => "aarch64-linux",
245        _ => {
246            // Fallback to compile-time detection
247            if cfg!(target_os = "macos") {
248                if cfg!(target_arch = "x86_64") {
249                    "x86_64-darwin"
250                } else {
251                    "aarch64-darwin"
252                }
253            } else {
254                if cfg!(target_arch = "x86_64") {
255                    "x86_64-linux"
256                } else {
257                    "aarch64-linux"
258                }
259            }
260        }
261    }
262}
263
264/// Runtime OS detection via uname.
265fn runtime_os() -> &'static str {
266    // Check /proc/version first (Linux-only, fast, no subprocess)
267    if Path::new("/proc/version").exists() {
268        return "linux";
269    }
270    // macOS has no /proc
271    if Path::new("/System/Library").exists() {
272        return "darwin";
273    }
274    // Fallback to compile-time
275    if cfg!(target_os = "macos") {
276        "darwin"
277    } else {
278        "linux"
279    }
280}
281
282/// Runtime architecture detection via uname -m.
283fn runtime_arch() -> &'static str {
284    if let Ok(output) = Command::new("uname").arg("-m").output() {
285        if output.status.success() {
286            let arch = crate::exec::captured_text(&output.stdout);
287            let arch = arch.trim();
288            return match arch {
289                "x86_64" => "x86_64",
290                "aarch64" | "arm64" => "aarch64",
291                _ => {
292                    if cfg!(target_arch = "x86_64") {
293                        "x86_64"
294                    } else {
295                        "aarch64"
296                    }
297                }
298            };
299        }
300    }
301    if cfg!(target_arch = "x86_64") {
302        "x86_64"
303    } else {
304        "aarch64"
305    }
306}
307
308/// Return the conventional repo directory name for this platform.
309pub fn default_repo_name() -> &'static str {
310    match detect_platform() {
311        Platform::Darwin => "macos-nix",
312        Platform::Linux => "nix-config",
313    }
314}
315
316#[cfg(test)]
317#[allow(clippy::unwrap_used)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_detect_system_returns_valid_string() {
323        let sys = detect_system();
324        assert!(
325            [
326                "x86_64-darwin",
327                "aarch64-darwin",
328                "x86_64-linux",
329                "aarch64-linux"
330            ]
331            .contains(&sys),
332            "detect_system returned unexpected: {sys}"
333        );
334    }
335
336    #[test]
337    fn test_detect_platform_consistent_with_system() {
338        let platform = detect_platform();
339        let system = detect_system();
340        match platform {
341            Platform::Darwin => assert!(system.ends_with("-darwin")),
342            Platform::Linux => assert!(system.ends_with("-linux")),
343        }
344    }
345
346    #[test]
347    fn test_runtime_arch_returns_known() {
348        let arch = runtime_arch();
349        assert!(
350            ["x86_64", "aarch64"].contains(&arch),
351            "runtime_arch returned unexpected: {arch}"
352        );
353    }
354
355    #[test]
356    fn test_platform_display() {
357        assert_eq!(format!("{}", Platform::Darwin), "macOS");
358        assert_eq!(format!("{}", Platform::Linux), "Linux");
359    }
360
361    #[test]
362    fn test_default_repo_name() {
363        let name = default_repo_name();
364        assert!(name == "macos-nix" || name == "nix-config");
365    }
366}