diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08b18b6..a331fec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Test run: cargo test --verbose - # Compile-check the non-x86_64 syscall paths (src/sys.rs) without a cross linker. + # Compile-check the non-x86_64 syscall paths (src/sys/linux.rs) without a cross linker. - name: Check (aarch64) run: cargo check --target aarch64-unknown-linux-gnu --verbose @@ -41,6 +41,45 @@ jobs: - name: Clippy run: cargo clippy --all-targets -- -D warnings + # Real macOS runs on both architectures: build, test, then execute the + # binary and assert the darwin detection paths actually produce output. + # macos-latest = arm64 (Apple Silicon); macos-26-intel = x86_64. + macos: + strategy: + matrix: + runner: [macos-latest, macos-26-intel] + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Build (release) + run: cargo build --release --verbose + + - name: Test + run: cargo test --verbose + + # The ubuntu job never compiles the darwin cfg paths; lint them here. + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Run and check output + run: | + ./target/release/purefetch --no-config + ./target/release/purefetch --no-config --no-color --logo none | tee out.txt + grep -E "^OS +macOS" out.txt + grep -E "^Kernel +Darwin " out.txt + grep -E "^Memory +[0-9.]+ [GM]iB / [0-9.]+ GiB" out.txt + grep -E "^Disk \(/\) +[0-9.]+ [GT]iB / [0-9.]+ [GT]iB" out.txt + grep -E "^Uptime +" out.txt + grep -E "^DE +Aqua" out.txt + grep -E "^WM +Quartz Compositor" out.txt + grep -E "^Shell +" out.txt + grep -E "^CPU +" out.txt + # Prove the declared MSRV for real: build and test on the oldest supported # toolchain (this also requires Cargo.lock to stay in the v3 format). msrv: @@ -51,9 +90,14 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: toolchain: "1.70" + targets: aarch64-apple-darwin - name: Check (MSRV 1.70) run: cargo check --verbose - name: Test (MSRV 1.70) run: cargo test --verbose + + # The darwin cfg paths must hold the MSRV too (check-only, no linker). + - name: Check darwin (MSRV 1.70) + run: cargo check --target aarch64-apple-darwin --verbose diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f64058d..653d200 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,11 +5,13 @@ Thanks for your interest! purefetch aims to stay small, fast, and ## Ground rules -- **Zero dependencies.** No crates.io dependencies — the tool is `std` plus raw - Linux syscalls. A PR that adds a dependency will be asked to drop it. -- **No panics, no blocking.** Detection modules read `/proc` and `/sys` and must - return an empty `Vec` when data is missing rather than `unwrap()`-ing or - spawning long-running work. +- **Zero dependencies.** No crates.io dependencies — the tool is `std` plus the + platform layer in `src/sys/` (raw Linux syscalls; direct libSystem FFI on + macOS). A PR that adds a dependency will be asked to drop it. +- **No panics, no blocking.** Detection modules read `/proc` and `/sys` on + Linux (sysctl & co. via `crate::sys` on macOS) and must return an empty + `Vec` when data is missing rather than `unwrap()`-ing or spawning + long-running work. - **MSRV is 1.70** — don't use newer `std` APIs (a dedicated CI job builds and tests on 1.70; `Cargo.lock` stays in the v3 format so pre-1.78 cargo can read it). - Run `cargo fmt` and `cargo clippy` before submitting; CI enforces both. @@ -50,15 +52,23 @@ then register it (label + function) in the `groups` table in `src/main.rs`. Use `Row::val(...)` for a single value and return an empty `Vec` when unavailable. `src/detect/cpu.rs` is the canonical example. -## Adding a CPU architecture +## Adding a CPU architecture (Linux) -`src/sys.rs` issues raw syscalls per architecture. To add one, provide a +`src/sys/linux.rs` issues raw syscalls per architecture. To add one, provide a `syscall3` implementation (inline asm for that target's syscall convention) plus the `SYS_STATFS` / `SYS_IOCTL` numbers, all behind `#[cfg(target_arch = "...")]`. The `struct Statfs` layout is shared by LP64 targets; 32-bit targets would need their own (`statfs64`) layout. Verify with `cargo check --target ` and, ideally, a run under `qemu-`. +## Adding an OS + +One submodule in `src/sys/` implementing the API listed in `src/sys/mod.rs`, +plus a `#[cfg(target_os = "...")] pub fn detect()` per detection module that +differs. macOS (`src/sys/darwin.rs`) is the template: FFI structs mirror the +OS's C headers and every helper degrades to `None`/empty instead of failing. +Keep pure parsing helpers unconditional so the tests cover them on every host. + ## Commit & PR style - Small, focused commits with a `: ` subject line. diff --git a/README.md b/README.md index 9275680..881bf57 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,15 @@ A small, fast system-information tool — a [fastfetch](https://github.com/fastfetch-cli/fastfetch)-style fetcher written **entirely in Rust with zero external crates**. -No `libc`, no `sysinfo`, no `nix`, no color crate — nothing from crates.io. The -handful of syscalls that have no `std` wrapper (`statfs`, `ioctl` for the -terminal size / tty check) are issued directly as raw Linux syscalls (x86_64, -aarch64, riscv64 and loongarch64) via `core::arch::asm!` in `src/sys.rs`. -Everything else is `std` plus parsing of `/proc` and `/sys`. It builds offline -and has a trivial dependency graph. +No `libc`, no `sysinfo`, no `nix`, no color crate — nothing from crates.io. +On **Linux**, the handful of syscalls that have no `std` wrapper (`statfs`, +`ioctl` for the terminal size / tty check) are issued directly as raw syscalls +(x86_64, aarch64, riscv64 and loongarch64) via `core::arch::asm!`; everything +else is `std` plus parsing of `/proc` and `/sys`. On **macOS**, where raw +syscalls are not a stable ABI, the same platform layer binds `extern "C"` +straight to libSystem (statfs, sysctl, mach VM statistics, libproc) — which +Rust's `std` already links — so the crate stays free of external dependencies +there too. It builds offline and has a trivial dependency graph.

purefetch running on Debian

@@ -72,9 +75,11 @@ nix run github:ooonea/purefetch ``` Or add the flake as an input and use `purefetch.packages.${system}.default` -(built for `x86_64-linux` and `aarch64-linux`; there is also an `overlays.default`). +(built for `x86_64-linux`, `aarch64-linux`, `aarch64-darwin` and +`x86_64-darwin`; there is also an `overlays.default`). -Targets **Linux** on **x86_64**, **aarch64**, **riscv64**, and **loongarch64**. +Targets **Linux** on **x86_64**, **aarch64**, **riscv64**, and **loongarch64**, +and **macOS** (Apple Silicon and Intel). ## Usage @@ -101,8 +106,9 @@ Colors are disabled automatically when stdout is not a terminal (or when Bundled logos: `arch`, `ubuntu`, `fedora`, `debian`, `mint`, `manjaro`, `pop`, `opensuse`, `alpine`, `void`, `nixos`, `gentoo`, `endeavouros`, `kali`, `elementary`, `zorin`, `artix`, `rocky`, `almalinux`, `centos`, `devuan`, `mx`, -`garuda`, `tux` (and `none`). `auto` picks one from `/etc/os-release`, falling -back to `tux`. +`garuda`, `macos` (alias `apple`), `tux` (and `none`). `auto` picks one from +`/etc/os-release` on Linux (falling back to `tux`) and always the Apple logo +on macOS. ## Configuration @@ -132,14 +138,20 @@ skipped. ## Notable details -- **Zero dependencies.** The whole tool is `std` + raw syscalls (`src/sys.rs`). +- **Zero dependencies.** The whole tool is `std` + the platform layer in + `src/sys/` (raw syscalls on Linux, libSystem FFI on macOS). - **ZFS-aware.** `Memory` is `MemTotal - MemAvailable` (matching `free`/htop) — the ARC counts as used because it genuinely occupies RAM. On a ZFS root, `Disk` reports the whole pool via `zpool list` (labelled `Disk ()`), not just the root dataset's few GiB. -- **Process-parent detection.** `Shell` and `Terminal` walk the `/proc/` - parent chain (parsing `ppid` after the last `)` in `stat`, reading the clean - name from `comm`), with environment-variable fallbacks (`$TERM`, +- **macOS semantics match the natives.** `Memory` follows `vm_stat`/Activity + Monitor (total minus really-free and file-backed pages), `Disk (/)` reports + the shared APFS container with used = total - available (like fastfetch), + `Display` lists native pixels via CoreGraphics, and `Host` shows the device + tree's marketing name over the board id — no model lookup table to age. +- **Process-parent detection.** `Shell` and `Terminal` walk the parent chain + (`/proc//stat`+`comm` on Linux, `proc_pidinfo` on macOS), with + environment-variable fallbacks (`$TERM`, `$TERM_PROGRAM`, `$KITTY_WINDOW_ID`, ...) for the terminal. - **Best-effort everywhere.** No module ever panics or blocks; missing data just drops its line. @@ -149,14 +161,17 @@ skipped. ``` src/ main.rs arg parsing, module ordering, title/separators/color blocks, dispatch - sys.rs raw Linux syscalls (x86_64, aarch64, riscv64, loongarch64): statfs, ioctl + sys/ + mod.rs platform-layer API (disk_usage, term_width, hostname, ppid_comm, ...) + linux.rs raw Linux syscalls (x86_64, aarch64, riscv64, loongarch64): statfs, ioctl + darwin.rs libSystem FFI: statfs, sysctl, mach VM stats, libproc, CoreGraphics util.rs file helpers, subprocess helper, byte/percent formatting color.rs ANSI palette - logo.rs distro ASCII logos + selection (generated by examples/genlogos.rs) + logo.rs OS ASCII logos + selection (generated by examples/genlogos.rs) render.rs logo-left / info-right layout, ANSI-aware width & truncation detect/ mod.rs Row/Rows contract + module registry - *.rs one module per info line (os, cpu, gpu, memory, ...) + *.rs one module per info line (os, cpu, gpu, memory, ...), cfg-split per OS ``` Adding an info source is one file: implement diff --git a/assets/logos/macos.txt b/assets/logos/macos.txt new file mode 100644 index 0000000..580c14e --- /dev/null +++ b/assets/logos/macos.txt @@ -0,0 +1,18 @@ +COLORS: 32 33 91 31 35 34 + ..' + ,xNMM. + .OMMMMo + lMM" + .;loddo:. .olloddol;. + cKMMMMMMMMMMNWMMMMMMMMMM0: + $2.KMMMMMMMMMMMMMMMMMMMMMMMWd. + XMMMMMMMMMMMMMMMMMMMMMMMX. +$3;MMMMMMMMMMMMMMMMMMMMMMMM: +:MMMMMMMMMMMMMMMMMMMMMMMM: +$4.MMMMMMMMMMMMMMMMMMMMMMMMX. + kMMMMMMMMMMMMMMMMMMMMMMMMWd. + $5'XMMMMMMMMMMMMMMMMMMMMMMMMMMk + 'XMMMMMMMMMMMMMMMMMMMMMMMMK. + $6kMMMMMMMMMMMMMMMMMMMMMMd + ;KMMMMMMMWXXWMMMMMMMk. + "cooc*" "*coo'" diff --git a/examples/genlogos.rs b/examples/genlogos.rs index 865c241..956beac 100644 --- a/examples/genlogos.rs +++ b/examples/genlogos.rs @@ -41,6 +41,7 @@ const ORDER: &[(&str, &[&str])] = &[ ("devuan", &["devuan"]), ("mx", &["mx"]), ("garuda", &["garuda"]), + ("macos", &["macos", "apple", "osx", "mac"]), ("tux", &["tux", "linux", "generic"]), ]; @@ -167,9 +168,9 @@ pub struct Logo { pub colors: &'static [&'static str], } -/// Resolve a logo selector ("auto", "debian", "none", ...) to a logo. +/// Resolve a logo selector ("auto", "debian", "macos", "none", ...) to a logo. /// A known name wins; an unknown *explicit* name falls back to the detected -/// distro (matching fastfetch), and finally to the generic Tux logo. +/// OS (matching fastfetch), and finally to the generic Tux logo. pub fn get(selector: &str) -> Option { let sel = selector.to_ascii_lowercase(); if sel == "none" || sel == "off" { @@ -184,6 +185,7 @@ pub fn get(selector: &str) -> Option { } /// The `ID` from /etc/os-release, normalized to a known logo name. +#[cfg(target_os = "linux")] fn detect_distro() -> String { let id = std::fs::read_to_string("/etc/os-release") .ok() @@ -197,7 +199,14 @@ fn detect_distro() -> String { normalize(&id) } +/// macOS is one OS: always the Apple logo. +#[cfg(target_os = "macos")] +fn detect_distro() -> String { + "macos".to_string() +} + /// Map os-release IDs to the logo names we ship. +#[cfg(target_os = "linux")] fn normalize(id: &str) -> String { if id.starts_with("opensuse") { return "opensuse".to_string(); diff --git a/flake.nix b/flake.nix index fae525c..ae6c43c 100644 --- a/flake.nix +++ b/flake.nix @@ -8,10 +8,12 @@ outputs = { self, nixpkgs }: let - # purefetch is Linux-only (raw /proc, /sys and Linux syscalls). + # Linux via raw syscalls + /proc + /sys; macOS via libSystem FFI. systems = [ "x86_64-linux" "aarch64-linux" + "aarch64-darwin" + "x86_64-darwin" ]; forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); in @@ -31,7 +33,7 @@ asl20 ]; mainProgram = "purefetch"; - platforms = pkgs.lib.platforms.linux; + platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin; }; }; default = purefetch; diff --git a/src/detect/battery.rs b/src/detect/battery.rs index e455152..13dd45c 100644 --- a/src/detect/battery.rs +++ b/src/detect/battery.rs @@ -1,9 +1,61 @@ //! Battery: charge percent + status, e.g. "87% (Discharging)". -//! Reads /sys/class/power_supply/*/ entries whose "type" is "Battery" -//! (excludes Mains/AC and ucsi-source-psy-* USB-C PD sources). -//! Skipped on desktops with no battery. +//! Linux reads /sys/class/power_supply/*/ entries whose "type" is "Battery" +//! (excludes Mains/AC and ucsi-source-psy-* USB-C PD sources). macOS parses +//! `pmset -g batt` — the stable power-management CLI; there is no file/sysctl +//! source. Skipped on desktops with no battery. use crate::detect::{Row, Rows}; +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + let Some(out) = crate::util::cmd("pmset", &["-g", "batt"]) else { + return Vec::new(); + }; + parse_pmset(&out) +} + +/// One row per "InternalBattery" line, e.g. +/// ` -InternalBattery-0 (id=123)\t87%; discharging; 3:42 remaining ...` +/// -> "87% (Discharging)". +#[cfg(any(target_os = "macos", test))] +fn parse_pmset(out: &str) -> Rows { + out.lines() + .filter(|l| l.contains("InternalBattery")) + .filter_map(|l| { + let pct_end = l.find('%')?; + let digits_rev: String = l[..pct_end] + .chars() + .rev() + .take_while(char::is_ascii_digit) + .collect(); + let pct: String = digits_rev.chars().rev().collect(); + if pct.is_empty() { + return None; + } + // The status word sits between the first two ';' separators. + let status = l[pct_end + 1..] + .trim_start_matches(';') + .split(';') + .next() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(capitalize) + .unwrap_or_else(|| "Unknown".to_string()); + Some(Row::val(format!("{pct}% ({status})"))) + }) + .collect() +} + +/// Uppercase the first ASCII letter: "discharging" -> "Discharging". +#[cfg(any(target_os = "macos", test))] +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(), + None => String::new(), + } +} + +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let Ok(entries) = std::fs::read_dir("/sys/class/power_supply") else { return Vec::new(); @@ -32,3 +84,25 @@ pub fn detect() -> Rows { } rows } + +#[cfg(test)] +mod tests { + use super::parse_pmset; + + #[test] + fn pmset_lines_parse_percent_and_status() { + let out = "Now drawing from 'Battery Power'\n \ + -InternalBattery-0 (id=6357091)\t87%; discharging; 3:42 remaining present: true\n"; + let rows = parse_pmset(out); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].value, "87% (Discharging)"); + + // AC power, charged, single-digit percent elsewhere. + let out = "Now drawing from 'AC Power'\n \ + -InternalBattery-0 (id=1)\t100%; charged; 0:00 remaining present: true\n"; + assert_eq!(parse_pmset(out)[0].value, "100% (Charged)"); + + // A desktop Mac: no InternalBattery lines at all. + assert!(parse_pmset("Now drawing from 'AC Power'\n").is_empty()); + } +} diff --git a/src/detect/cpu.rs b/src/detect/cpu.rs index 6052e0c..b499cc0 100644 --- a/src/detect/cpu.rs +++ b/src/detect/cpu.rs @@ -1,11 +1,12 @@ //! CPU: cleaned model name @ max frequency, -//! e.g. "Intel(R) Core(TM) i7-9850H @ 4.60 GHz". +//! e.g. "Intel(R) Core(TM) i7-9850H @ 4.60 GHz" or "Apple M1". //! //! Architectures label the CPU differently in /proc/cpuinfo: x86 and most ARM //! use "model name"; riscv uses "uarch" or the "isa" string; ppc uses "cpu"; //! some ARM SoCs use "Hardware". We take the first present, in that order. use crate::detect::{Row, Rows}; +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let Ok(cpuinfo) = std::fs::read_to_string("/proc/cpuinfo") else { return Vec::new(); @@ -22,7 +23,24 @@ pub fn detect() -> Rows { vec![Row::val(value)] } +/// macOS: the brand string covers both worlds ("Apple M1", "Intel(R) Core(TM) +/// i7-..."). The nominal max frequency only exists as a sysctl on Intel; +/// Apple Silicon does not publish one, so the name stands alone there. +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + let Some(model) = crate::sys::sysctl_string("machdep.cpu.brand_string") else { + return Vec::new(); + }; + let name = clean_model(&model); + let value = match crate::sys::sysctl_u64("hw.cpufrequency_max") { + Some(hz) if hz > 0 => format!("{name} @ {:.2} GHz", hz as f64 / 1e9), + _ => name, + }; + vec![Row::val(value)] +} + /// The CPU name from /proc/cpuinfo, trying each key in preference order. +#[cfg(any(target_os = "linux", test))] fn model_name(cpuinfo: &str) -> Option { const KEYS: [&str; 6] = ["model name", "Hardware", "cpu model", "cpu", "uarch", "isa"]; for key in KEYS { @@ -48,6 +66,7 @@ fn clean_model(s: &str) -> String { base.split_whitespace().collect::>().join(" ") } +#[cfg(target_os = "linux")] fn max_freq_ghz() -> Option { crate::util::read_trim("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq") .and_then(|s| s.parse::().ok()) diff --git a/src/detect/de.rs b/src/detect/de.rs index 9c108e1..ac50d20 100644 --- a/src/detect/de.rs +++ b/src/detect/de.rs @@ -3,8 +3,16 @@ //! normalized), falling back to $XDG_SESSION_DESKTOP then $DESKTOP_SESSION. //! Version comes from the DE's own tool (gnome-shell/plasmashell); if unknown //! only the name is shown. Empty Vec on a tty / pure WM (no DE hints). +//! macOS: always "Aqua" — the desktop is fixed (neofetch's convention; recent +//! fastfetch files the design language under Theme instead). use crate::detect::{Row, Rows}; +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + vec![Row::val("Aqua")] +} + +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let raw = desktop_hint(); let Some(raw) = raw else { @@ -24,6 +32,7 @@ pub fn detect() -> Rows { } /// First of the desktop env vars that is set and non-empty. +#[cfg(target_os = "linux")] fn desktop_hint() -> Option { for key in [ "XDG_CURRENT_DESKTOP", @@ -42,6 +51,7 @@ fn desktop_hint() -> Option { /// Map a raw desktop token to a canonical display name; unknown tokens pass /// through unchanged. +#[cfg(any(target_os = "linux", test))] fn normalize(token: &str) -> String { let lower = token.to_ascii_lowercase(); let name = if lower.contains("gnome") { @@ -75,6 +85,7 @@ fn normalize(token: &str) -> String { } /// DE version string, or None if it cannot be determined. +#[cfg(target_os = "linux")] fn version_for(name: &str) -> Option { match name { "GNOME" => { @@ -103,6 +114,7 @@ fn version_for(name: &str) -> Option { /// First whitespace-separated token that begins with a digit, /// e.g. "GNOME Shell 48.7" -> "48.7". +#[cfg(any(target_os = "linux", test))] fn first_num_token(s: &str) -> Option { s.split_whitespace() .find(|t| t.chars().next().is_some_and(|c| c.is_ascii_digit())) diff --git a/src/detect/disk.rs b/src/detect/disk.rs index 962dadd..213f0ab 100644 --- a/src/detect/disk.rs +++ b/src/detect/disk.rs @@ -5,9 +5,12 @@ //! pool via `/nix` instead, so the disk still reads as the real pool, not the //! few-MiB RAM root. use crate::detect::{Row, Rows}; -use crate::util::{cmd, human_iec, percent}; +#[cfg(target_os = "linux")] +use crate::util::cmd; +use crate::util::{human_iec, percent}; pub fn detect() -> Rows { + #[cfg(target_os = "linux")] if let Some(row) = zfs_pool_row() { return vec![row]; } @@ -23,6 +26,7 @@ pub fn detect() -> Rows { } /// When `/` is ZFS, report its pool's allocated/size via `zpool list`. +#[cfg(target_os = "linux")] fn zfs_pool_row() -> Option { let pool = zfs_root_pool()?; // `zpool list -Hp -o size,alloc ` -> "\t" in bytes. @@ -47,6 +51,7 @@ fn zfs_pool_row() -> Option { /// The ZFS pool the system lives on: `/`'s pool on a ZFS-root box, or — when `/` /// is tmpfs (impermanence) — the pool backing `/nix`, where the store actually /// lives. Returns the first component of the dataset name (`zroot/nix` -> `zroot`). +#[cfg(target_os = "linux")] fn zfs_root_pool() -> Option { let mounts = std::fs::read_to_string("/proc/mounts").ok()?; let pool_at = |want: &str| { diff --git a/src/detect/display.rs b/src/detect/display.rs index 0ccc1a3..416ba03 100644 --- a/src/detect/display.rs +++ b/src/detect/display.rs @@ -1,7 +1,18 @@ //! Display: connected outputs and resolutions, e.g. "1920x1080 (eDP-1)". -//! Sourced from /sys/class/drm/cardN-/{status,modes}. +//! Linux: /sys/class/drm/cardN-/{status,modes}. macOS: the +//! CoreGraphics active-display list (native pixels; no connector names, so +//! the rows are bare "2560x1440"). Headless sessions show nothing on both. use crate::detect::{Row, Rows}; +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + crate::sys::displays() + .into_iter() + .map(|(w, h)| Row::val(format!("{w}x{h}"))) + .collect() +} + +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { return Vec::new(); @@ -43,6 +54,7 @@ pub fn detect() -> Rows { /// Strip a leading "cardN-" from a DRM node name: "card0-eDP-1" -> Some("eDP-1"). /// Returns None if the name is not a per-connector node (e.g. "card0", "renderD128"). +#[cfg(any(target_os = "linux", test))] fn strip_card_prefix(dir: &str) -> Option<&str> { let rest = dir.strip_prefix("card")?; // Consume the card index digits. diff --git a/src/detect/gpu.rs b/src/detect/gpu.rs index f01dc55..eff159b 100644 --- a/src/detect/gpu.rs +++ b/src/detect/gpu.rs @@ -1,11 +1,15 @@ -//! GPU: graphics adapter model, e.g. "Quadro RTX 3000". -//! Primary source: /proc/driver/nvidia/gpus/*/information ("Model:" line). -//! lspci covers non-NVIDIA adapters: as the sole source when the nvidia proc -//! is absent, and additionally for the other card on hybrid (iGPU + NVIDIA) -//! systems — detected first via /sys so the common single-GPU run stays -//! subprocess-free. +//! GPU: graphics adapter model, e.g. "Quadro RTX 3000" or "Apple M1 (8)". +//! Linux primary source: /proc/driver/nvidia/gpus/*/information ("Model:" +//! line). lspci covers non-NVIDIA adapters: as the sole source when the +//! nvidia proc is absent, and additionally for the other card on hybrid +//! (iGPU + NVIDIA) systems — detected first via /sys so the common single-GPU +//! run stays subprocess-free. +//! macOS: the IOAccelerator registry entry via `ioreg` (model + core count), +//! with the CPU brand string as fallback — on Apple Silicon the chip name IS +//! the GPU name. use crate::detect::{Row, Rows}; +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let mut rows = nvidia_gpus(); if rows.is_empty() { @@ -17,8 +21,28 @@ pub fn detect() -> Rows { rows } +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + if let Some(dump) = crate::util::cmd("ioreg", &["-rc", "IOAccelerator", "-w0"]) { + if let Some(model) = crate::util::ioreg_string(&dump, "model") { + let value = match crate::util::ioreg_u64(&dump, "gpu-core-count") { + Some(cores) if cores > 0 => format!("{model} ({cores})"), + _ => model, + }; + return vec![Row::val(value)]; + } + } + // No accelerator entry (e.g. a bare VM): on Apple Silicon the chip name + // still names the GPU honestly; otherwise report nothing. + match crate::sys::sysctl_string("machdep.cpu.brand_string") { + Some(chip) if chip.starts_with("Apple") => vec![Row::val(chip)], + _ => Vec::new(), + } +} + /// Whether /sys/class/drm has a card from a non-NVIDIA vendor (PCI vendor id /// != 0x10de), i.e. an adapter the NVIDIA proc interface cannot report. +#[cfg(target_os = "linux")] fn has_non_nvidia_card() -> bool { let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { return false; @@ -40,6 +64,7 @@ fn has_non_nvidia_card() -> bool { } /// One Row per NVIDIA GPU, parsed from the driver's proc information file. +#[cfg(target_os = "linux")] fn nvidia_gpus() -> Rows { let mut rows = Vec::new(); let Ok(entries) = std::fs::read_dir("/proc/driver/nvidia/gpus") else { @@ -69,6 +94,7 @@ fn nvidia_gpus() -> Rows { /// Parse `lspci` for graphics controllers; with `skip_nvidia`, NVIDIA cards /// are left out (already reported from the driver's proc interface). +#[cfg(target_os = "linux")] fn lspci_gpus(skip_nvidia: bool) -> Rows { let Some(out) = crate::util::cmd("lspci", &[]) else { return Vec::new(); @@ -97,6 +123,7 @@ fn lspci_gpus(skip_nvidia: bool) -> Rows { } /// Strip a known vendor prefix and prefer a bracketed marketing name. +#[cfg(any(target_os = "linux", test))] fn clean_lspci(desc: &str) -> String { let stripped = desc .strip_prefix("NVIDIA Corporation ") diff --git a/src/detect/host.rs b/src/detect/host.rs index 8094bc2..dca38df 100644 --- a/src/detect/host.rs +++ b/src/detect/host.rs @@ -1,9 +1,11 @@ //! Host: machine model. On Lenovo the marketing name lives in `product_version` //! ("ThinkPad P53") and the model code in `product_name` ("20QQS0JD01"), so we -//! show "ThinkPad P53 (20QQS0JD01)". +//! show "ThinkPad P53 (20QQS0JD01)". On a Mac: "Mac mini (Mac14,3)". use crate::detect::{Row, Rows}; +#[cfg(target_os = "linux")] use crate::util::read_trim; +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let base = "/sys/devices/virtual/dmi/id"; let product_name = read_trim(&format!("{base}/product_name")); @@ -30,6 +32,24 @@ pub fn detect() -> Rows { } /// A human-readable model name has letters and a space, unlike a bare serial. +#[cfg(target_os = "linux")] fn looks_like_name(s: &str) -> bool { s.chars().any(|c| c.is_ascii_alphabetic()) && s.contains(' ') } + +/// macOS: the marketing name ("Mac mini") lives in the device tree as +/// `product-name` on Apple Silicon — `ioreg` is the only zero-dependency way +/// at it. The board id (hw.model, "Mac14,3") is the always-available +/// fallback; no static model table to keep up to date. +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + let model = crate::sys::sysctl_string("hw.model"); + let marketing = crate::util::cmd("ioreg", &["-rd1", "-c", "IOPlatformExpertDevice"]) + .and_then(|dump| crate::util::ioreg_string(&dump, "product-name")); + match (marketing, model) { + (Some(name), Some(id)) if name != id => vec![Row::val(format!("{name} ({id})"))], + (Some(name), _) => vec![Row::val(name)], + (None, Some(id)) => vec![Row::val(id)], + (None, None) => Vec::new(), + } +} diff --git a/src/detect/kernel.rs b/src/detect/kernel.rs index 9235354..ad2e314 100644 --- a/src/detect/kernel.rs +++ b/src/detect/kernel.rs @@ -1,9 +1,21 @@ -//! Kernel: release string, e.g. "6.12.94+deb13-amd64". +//! Kernel: release string, e.g. "6.12.94+deb13-amd64" or "Darwin 25.4.0". use crate::detect::{Row, Rows}; +#[cfg(target_os = "linux")] pub fn detect() -> Rows { match crate::util::read_trim("/proc/sys/kernel/osrelease") { Some(v) => vec![Row::val(v)], None => Vec::new(), } } + +/// macOS: a bare Darwin release number would read like nonsense next to the +/// macOS version, so prefix the kernel name (matching fastfetch). +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + let Some(release) = crate::sys::sysctl_string("kern.osrelease") else { + return Vec::new(); + }; + let name = crate::sys::sysctl_string("kern.ostype").unwrap_or_else(|| "Darwin".to_string()); + vec![Row::val(format!("{name} {release}"))] +} diff --git a/src/detect/memory.rs b/src/detect/memory.rs index 428feeb..d8c6c01 100644 --- a/src/detect/memory.rs +++ b/src/detect/memory.rs @@ -1,11 +1,13 @@ //! Memory: used / total (percent), e.g. "37.69 GiB / 62.61 GiB (60%)". -//! Used = MemTotal - MemAvailable, matching `free` and htop. On ZFS the ARC -//! counts as used because MemAvailable does not treat it as reclaimable — which -//! is honest: that RAM is genuinely occupied by the cache until the kernel -//! reclaims it under pressure. +//! Linux: used = MemTotal - MemAvailable, matching `free` and htop. On ZFS the +//! ARC counts as used because MemAvailable does not treat it as reclaimable — +//! which is honest: that RAM is genuinely occupied by the cache until the +//! kernel reclaims it under pressure. +//! macOS: mach vm statistics, matching vm_stat and Activity Monitor. use crate::detect::{Row, Rows}; use crate::util::{human_iec, percent}; +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let Ok(info) = std::fs::read_to_string("/proc/meminfo") else { return Vec::new(); @@ -24,14 +26,27 @@ pub fn detect() -> Rows { } let used = total.saturating_sub(available); - vec![Row::val(format!( + vec![row(used, total)] +} + +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + let Some((used, total)) = crate::sys::memory_used_total() else { + return Vec::new(); + }; + vec![row(used, total)] +} + +fn row(used: u64, total: u64) -> Row { + Row::val(format!( "{} / {} ({}%)", human_iec(used), human_iec(total), percent(used, total) - ))] + )) } +#[cfg(any(target_os = "linux", test))] fn parse_kb(s: &str) -> u64 { s.split_whitespace() .next() diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 9ce2445..fbbd739 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -11,8 +11,11 @@ //! entirely (e.g. `battery` on a desktop, `swap` with no swap configured). //! * `Row::val(v)` uses the default label supplied by `main`. `Row::keyed(k, v)` //! overrides the label (used when a module emits several distinct rows). -//! * Never panic and never block: read `/proc` and `/sys`, or fall back to -//! `crate::util::cmd(...)` only where no file source exists. +//! * Never panic and never block: read `/proc` and `/sys` on Linux, or go +//! through `crate::sys` (sysctl/libSystem) on macOS; fall back to +//! `crate::util::cmd(...)` only where no file/sysctl source exists. +//! * OS-specific modules provide one `detect()` per `target_os`; pure parsing +//! helpers stay unconditional so tests cover them everywhere. pub struct Row { pub key: Option, @@ -40,6 +43,15 @@ impl Row { pub type Rows = Vec; +/// Architecture label, uname-style: macOS calls its 64-bit ARM "arm64". +pub fn arch() -> &'static str { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "arm64" + } else { + std::env::consts::ARCH + } +} + pub mod battery; pub mod cpu; pub mod de; diff --git a/src/detect/os.rs b/src/detect/os.rs index 46fb55b..4ea0a4e 100644 --- a/src/detect/os.rs +++ b/src/detect/os.rs @@ -1,7 +1,8 @@ -//! OS: distro name + version + codename + arch, -//! e.g. "Debian GNU/Linux 13.5 (trixie) x86_64". +//! OS: name + version + codename + arch, +//! e.g. "Debian GNU/Linux 13.5 (trixie) x86_64" or "macOS 26.1 (Tahoe) arm64". use crate::detect::{Row, Rows}; +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let mut id = String::new(); let mut name = String::new(); @@ -31,7 +32,7 @@ pub fn detect() -> Rows { .map(|s| s.trim().to_string()); let version = effective_version(&id, version_id, debian_version); - let arch = std::env::consts::ARCH; + let arch = crate::detect::arch(); let base = if !name.is_empty() { let mut b = name; @@ -52,10 +53,64 @@ pub fn detect() -> Rows { vec![Row::val(format!("{base} {arch}"))] } +/// macOS: version + build from SystemVersion.plist — the same file `sw_vers` +/// reads, present on every macOS — plus the marketing codename we map locally. +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + let plist = std::fs::read_to_string("/System/Library/CoreServices/SystemVersion.plist") + .unwrap_or_default(); + let name = plist_value(&plist, "ProductName").unwrap_or_else(|| "macOS".to_string()); + let version = plist_value(&plist, "ProductVersion").unwrap_or_default(); + + let mut base = name; + if !version.is_empty() { + base.push(' '); + base.push_str(&version); + } + if let Some(code) = codename(&version) { + base.push_str(&format!(" ({code})")); + } + vec![Row::val(format!("{base} {}", crate::detect::arch()))] +} + +/// Value of `KV` in a plist, by simple scanning — +/// SystemVersion.plist is flat, tiny and Apple-generated, so a real XML parser +/// would be overkill. +#[cfg(any(target_os = "macos", test))] +fn plist_value(xml: &str, key: &str) -> Option { + let pos = xml.find(&format!("{key}"))?; + let rest = &xml[pos..]; + let start = rest.find("")? + "".len(); + let end = rest[start..].find("")?; + let v = rest[start..start + end].trim(); + if v.is_empty() { + None + } else { + Some(v.to_string()) + } +} + +/// Marketing codename for a macOS version, from the major number (Apple's +/// scheme since 11; 26 followed 15 with the switch to year-based numbering). +#[cfg(any(target_os = "macos", test))] +fn codename(version: &str) -> Option<&'static str> { + let major: u32 = version.split('.').next()?.parse().ok()?; + Some(match major { + 11 => "Big Sur", + 12 => "Monterey", + 13 => "Ventura", + 14 => "Sonoma", + 15 => "Sequoia", + 26 => "Tahoe", + _ => return None, + }) +} + /// Debian ships a fuller version in /etc/debian_version (e.g. "13.5") than /// os-release VERSION_ID ("13"), so prefer it — but ONLY on Debian itself: on /// derivatives (Ubuntu, Mint, ...) the same file holds the BASE distro's /// codename (e.g. "trixie/sid"), not the derivative's own version. +#[cfg(any(target_os = "linux", test))] fn effective_version(id: &str, version_id: String, debian_version: Option) -> String { if id == "debian" { if let Some(v) = debian_version.filter(|s| !s.is_empty()) { @@ -67,7 +122,32 @@ fn effective_version(id: &str, version_id: String, debian_version: Option + + ProductBuildVersion + 25E246 + ProductName + macOS + ProductVersion + 26.4 +"#; + assert_eq!(plist_value(xml, "ProductName").as_deref(), Some("macOS")); + assert_eq!(plist_value(xml, "ProductVersion").as_deref(), Some("26.4")); + assert_eq!(plist_value(xml, "Missing"), None); + } + + #[test] + fn codename_maps_major_version() { + assert_eq!(codename("15.1"), Some("Sequoia")); + assert_eq!(codename("26.4"), Some("Tahoe")); + assert_eq!(codename("14"), Some("Sonoma")); + assert_eq!(codename("9.99"), None); + assert_eq!(codename(""), None); + } #[test] fn debian_version_only_refines_debian_itself() { diff --git a/src/detect/packages.rs b/src/detect/packages.rs index b275be1..bd9945d 100644 --- a/src/detect/packages.rs +++ b/src/detect/packages.rs @@ -1,9 +1,58 @@ //! Packages: installed counts per manager, fastfetch-style, -//! e.g. "2422 (dpkg), 1 (flatpak)". Only managers with count>0, order dpkg, -//! flatpak, snap. Single Row. +//! e.g. "2422 (dpkg), 1 (flatpak)" or "291 (brew), 13 (brew-cask)". Only +//! managers with count>0. Single Row. Everything is counted from the +//! managers' on-disk layouts — no package-manager subprocesses. use crate::detect::{Row, Rows}; +#[cfg(target_os = "linux")] use std::collections::HashSet; +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + // Homebrew keeps one directory per installed formula (Cellar) or cask + // (Caskroom); $HOMEBREW_PREFIX overrides the per-arch default prefix. + let prefix = std::env::var("HOMEBREW_PREFIX") + .ok() + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| { + if cfg!(target_arch = "aarch64") { + "/opt/homebrew".to_string() + } else { + "/usr/local".to_string() + } + }); + + let mut parts: Vec = Vec::new(); + for (dir, label) in [ + (format!("{prefix}/Cellar"), "brew"), + (format!("{prefix}/Caskroom"), "brew-cask"), + // MacPorts: one directory per installed port. + ("/opt/local/var/macports/software".to_string(), "macports"), + ] { + let n = dir_count(&dir); + if n > 0 { + parts.push(format!("{n} ({label})")); + } + } + if parts.is_empty() { + return Vec::new(); + } + vec![Row::val(parts.join(", "))] +} + +/// Non-hidden subdirectories of `path` (0 if it does not exist). +#[cfg(target_os = "macos")] +fn dir_count(path: &str) -> usize { + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + entries + .flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .filter(|e| !e.file_name().to_string_lossy().starts_with('.')) + .count() +} + +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let mut parts: Vec = Vec::new(); @@ -30,6 +79,7 @@ pub fn detect() -> Rows { /// Count dpkg stanzas whose status line is exactly "install ok installed". /// Pure file read of /var/lib/dpkg/status, no subprocess. +#[cfg(target_os = "linux")] fn dpkg_count() -> usize { let Ok(status) = std::fs::read_to_string("/var/lib/dpkg/status") else { return 0; @@ -42,6 +92,7 @@ fn dpkg_count() -> usize { /// Count unique flatpak application ids across the system and user roots. /// Each subdirectory of an app/ root is one app id. +#[cfg(target_os = "linux")] fn flatpak_count() -> usize { let mut ids: HashSet = HashSet::new(); let mut roots: Vec = vec!["/var/lib/flatpak/app".to_string()]; @@ -66,6 +117,7 @@ fn flatpak_count() -> usize { } /// Count /snap//current symlinks, excluding the "bin" entry. +#[cfg(target_os = "linux")] fn snap_count() -> usize { let Ok(entries) = std::fs::read_dir("/snap") else { return 0; diff --git a/src/detect/shell.rs b/src/detect/shell.rs index 8c367a9..1980747 100644 --- a/src/detect/shell.rs +++ b/src/detect/shell.rs @@ -1,6 +1,6 @@ //! Shell: parent shell + version like fastfetch, e.g. "zsh 5.9". -//! Walks the process-parent chain from our own ppid up to the first ancestor -//! whose `comm` is a known shell, then reads its `--version`. +//! Walks the process-parent chain (via `sys::ppid_comm`) from our own ppid up +//! to the first ancestor whose name is a known shell, then reads its `--version`. use crate::detect::{Row, Rows}; /// Interactive/login shells we recognise as a "shell" ancestor. @@ -20,20 +20,18 @@ pub fn detect() -> Rows { vec![Row::val(value)] } -/// Climb the parent chain from our own ppid; return the `comm` of the first +/// Climb the parent chain from our own ppid; return the name of the first /// ancestor that is a known shell. `None` if none is found before pid 1. fn find_shell_ancestor() -> Option { - let mut pid = ppid_of("self")?; + let mut pid = crate::sys::ppid_comm(std::process::id())?.0; let mut guard = 0u32; while pid > 1 && guard < 64 { - // comm may carry a leading '-' for login shells, e.g. "-zsh". - if let Some(comm) = crate::util::read_trim(&format!("/proc/{pid}/comm")) { - let name = comm.strip_prefix('-').unwrap_or(&comm); - if SHELLS.contains(&name) { - return Some(name.to_string()); - } + let (next, comm) = crate::sys::ppid_comm(pid)?; + // The name may carry a leading '-' for login shells, e.g. "-zsh". + let name = comm.strip_prefix('-').unwrap_or(&comm); + if SHELLS.contains(&name) { + return Some(name.to_string()); } - let next = ppid_of(&pid.to_string())?; if next == pid { break; // cycle guard } @@ -43,15 +41,6 @@ fn find_shell_ancestor() -> Option { None } -/// Read the ppid field from `/proc//stat` (`who` = "self" or a pid string). -/// The process name can contain spaces/parens, so split after the LAST ')': -/// the remainder is "state ppid pgrp ..." — ppid is the 2nd field. -fn ppid_of(who: &str) -> Option { - let stat = crate::util::read_trim(&format!("/proc/{who}/stat"))?; - let after = &stat[stat.rfind(')')? + 1..]; - after.split_whitespace().nth(1)?.parse().ok() -} - /// Extract a version like "5.9" or "5.2.37" from ` --version`. /// Takes the first whitespace token that starts with a digit, then keeps the /// leading run of digits and dots (drops trailing "(1)-release" etc.). diff --git a/src/detect/swap.rs b/src/detect/swap.rs index 550df45..2c5c1b4 100644 --- a/src/detect/swap.rs +++ b/src/detect/swap.rs @@ -1,7 +1,9 @@ -//! Swap: used / total (percent). Skipped entirely if no swap is configured. +//! Swap: used / total (percent). Skipped entirely if no swap is configured +//! (macOS with no swap files yet reports a 0 total the same way). use crate::detect::{Row, Rows}; use crate::util::{human_iec, percent}; +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let Ok(info) = std::fs::read_to_string("/proc/meminfo") else { return Vec::new(); @@ -15,10 +17,21 @@ pub fn detect() -> Rows { free = parse_kb(v); } } + row(total.saturating_sub(free), total) +} + +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + let Some((total, used)) = crate::sys::swap_usage() else { + return Vec::new(); + }; + row(used, total) +} + +fn row(used: u64, total: u64) -> Rows { if total == 0 { return Vec::new(); } - let used = total.saturating_sub(free); vec![Row::val(format!( "{} / {} ({}%)", human_iec(used), @@ -27,6 +40,7 @@ pub fn detect() -> Rows { ))] } +#[cfg(target_os = "linux")] fn parse_kb(s: &str) -> u64 { s.split_whitespace() .next() diff --git a/src/detect/terminal.rs b/src/detect/terminal.rs index 8d18e67..a6719ad 100644 --- a/src/detect/terminal.rs +++ b/src/detect/terminal.rs @@ -10,7 +10,7 @@ pub fn detect() -> Rows { Some(t) => t, None => return Vec::new(), }; - let value = match version_of(term) { + let value = match version_of(term).or_else(|| env_version(term)) { Some(v) => format!("{term} {v}"), None => term.to_string(), }; @@ -19,34 +19,26 @@ pub fn detect() -> Rows { /// Climb the parent chain from our own ppid; return the first known terminal. fn walk_parents() -> Option<&'static str> { - let mut pid = ppid_of("/proc/self/stat")?; + let mut pid = crate::sys::ppid_comm(std::process::id())?.0; // Guard against cycles / runaway with a hard cap; stop at init. for _ in 0..64 { if pid <= 1 { break; } - if let Some(comm) = crate::util::read_trim(&format!("/proc/{pid}/comm")) { - if let Some(canon) = canonical(&comm) { - return Some(canon); - } + let Some((next, comm)) = crate::sys::ppid_comm(pid) else { + break; + }; + if let Some(canon) = canonical(&comm) { + return Some(canon); } - match ppid_of(&format!("/proc/{pid}/stat")) { - Some(next) if next != pid => pid = next, - _ => break, + if next == pid { + break; } + pid = next; } None } -/// Parse the ppid from a `/proc//stat` file. The comm field is enclosed in -/// parentheses and may itself contain spaces or ')', so we split on the LAST -/// ')': the remainder is "state ppid ...", where ppid is the 2nd whitespace field. -fn ppid_of(stat_path: &str) -> Option { - let text = crate::util::read_trim(stat_path)?; - let rest = &text[text.rfind(')')? + 1..]; - rest.split_whitespace().nth(1)?.parse::().ok() -} - /// Map a process `comm` (possibly truncated to 15 chars by the kernel) to a /// canonical terminal display name, or None if it is not a known terminal. fn canonical(comm: &str) -> Option<&'static str> { @@ -70,6 +62,10 @@ fn canonical(comm: &str) -> Option<&'static str> { ("ghostty", "ghostty"), ("tmux", "tmux"), ("screen", "screen"), + // macOS GUI terminals, as libproc reports their process names. + ("Terminal", "Apple Terminal"), + ("iTerm2", "iTerm2"), + ("WarpTerminal", "Warp"), ]; for (name, display) in TERMS { if comm == *name { @@ -159,6 +155,11 @@ fn from_token(tp: &str) -> Option<&'static str> { "wezterm" => Some("wezterm"), "tmux" => Some("tmux"), "konsole" => Some("konsole"), + // macOS conventions: Terminal.app and iTerm2 identify themselves only + // through this variable. + "apple_terminal" => Some("Apple Terminal"), + "iterm.app" => Some("iTerm2"), + "warpterminal" => Some("Warp"), _ if t.contains("foot") => Some("foot"), _ => None, } @@ -190,6 +191,20 @@ fn version_of(term: &str) -> Option { out.lines().next().and_then(version_token) } +/// $TERM_PROGRAM_VERSION, for terminals with no --version binary (Apple +/// Terminal, iTerm2, ...) — only when $TERM_PROGRAM names the same terminal +/// we detected, so a nested tmux doesn't inherit the outer app's version. +fn env_version(term: &str) -> Option { + let tp = std::env::var("TERM_PROGRAM").ok()?; + if from_token(&tp)? != term { + return None; + } + std::env::var("TERM_PROGRAM_VERSION") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + /// Pick the first whitespace token that looks like a version number, /// e.g. "kitty 0.41.1 created by ..." -> "0.41.1". A leading 'v' is stripped. fn version_token(line: &str) -> Option { diff --git a/src/detect/uptime.rs b/src/detect/uptime.rs index f0d12bd..b89a796 100644 --- a/src/detect/uptime.rs +++ b/src/detect/uptime.rs @@ -1,21 +1,34 @@ -//! Uptime: humanized from /proc/uptime, e.g. "6 days, 12 hours, 18 mins". +//! Uptime: humanized, e.g. "6 days, 12 hours, 18 mins". Linux reads +//! /proc/uptime; macOS derives it from the kern.boottime sysctl. use crate::detect::{Row, Rows}; pub fn detect() -> Rows { - let Some(raw) = crate::util::read_trim("/proc/uptime") else { - return Vec::new(); - }; - let secs = raw - .split_whitespace() - .next() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0) as u64; + let secs = uptime_secs().unwrap_or(0); if secs == 0 { return Vec::new(); } vec![Row::val(humanize(secs))] } +#[cfg(target_os = "linux")] +fn uptime_secs() -> Option { + let raw = crate::util::read_trim("/proc/uptime")?; + raw.split_whitespace() + .next() + .and_then(|s| s.parse::().ok()) + .map(|s| s as u64) +} + +#[cfg(target_os = "macos")] +fn uptime_secs() -> Option { + let boot = crate::sys::boottime_secs()?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + Some(now.saturating_sub(boot)) +} + fn humanize(mut s: u64) -> String { let days = s / 86_400; s %= 86_400; diff --git a/src/detect/wm.rs b/src/detect/wm.rs index 68f1125..a76b145 100644 --- a/src/detect/wm.rs +++ b/src/detect/wm.rs @@ -14,6 +14,7 @@ use crate::detect::{Row, Rows}; /// Left = comm as it appears in /proc; right = pretty display name. /// The exact match deliberately avoids helper processes such as /// "mutter-x11-fram" (mutter-x11-frames) that merely share a prefix. +#[cfg(target_os = "linux")] const KNOWN_WMS: &[(&str, &str)] = &[ ("kwin_wayland", "KWin"), ("kwin_x11", "KWin"), @@ -35,6 +36,15 @@ const KNOWN_WMS: &[(&str, &str)] = &[ ("qtile", "qtile"), ]; +/// macOS: the compositor is always WindowServer, presented under its +/// canonical name (fastfetch/neofetch convention). Tiling add-ons (yabai, +/// AeroSpace, ...) still run on top of it, so this stays honest either way. +#[cfg(target_os = "macos")] +pub fn detect() -> Rows { + vec![Row::val("Quartz Compositor")] +} + +#[cfg(target_os = "linux")] pub fn detect() -> Rows { let wm = match scan_processes() { Some(name) => name, @@ -51,6 +61,7 @@ pub fn detect() -> Rows { } /// Return the pretty name of the first known standalone WM found running. +#[cfg(target_os = "linux")] fn scan_processes() -> Option<&'static str> { let dir = std::fs::read_dir("/proc").ok()?; for entry in dir.flatten() { @@ -75,6 +86,7 @@ fn scan_processes() -> Option<&'static str> { /// Map the current desktop environment to its default WM/compositor. /// $XDG_CURRENT_DESKTOP may be a colon-separated list (e.g. "ubuntu:GNOME"). +#[cfg(target_os = "linux")] fn de_to_wm() -> Option<&'static str> { let desktop = std::env::var("XDG_CURRENT_DESKTOP").ok()?; for token in desktop.split(':') { @@ -94,6 +106,7 @@ fn de_to_wm() -> Option<&'static str> { } /// Pretty session type from $XDG_SESSION_TYPE, or None if not X11/Wayland. +#[cfg(target_os = "linux")] fn session_suffix() -> Option<&'static str> { match std::env::var("XDG_SESSION_TYPE") .ok()? diff --git a/src/logo.rs b/src/logo.rs index cd2398e..ba6b6ae 100644 --- a/src/logo.rs +++ b/src/logo.rs @@ -14,9 +14,9 @@ pub struct Logo { pub colors: &'static [&'static str], } -/// Resolve a logo selector ("auto", "debian", "none", ...) to a logo. +/// Resolve a logo selector ("auto", "debian", "macos", "none", ...) to a logo. /// A known name wins; an unknown *explicit* name falls back to the detected -/// distro (matching fastfetch), and finally to the generic Tux logo. +/// OS (matching fastfetch), and finally to the generic Tux logo. pub fn get(selector: &str) -> Option { let sel = selector.to_ascii_lowercase(); if sel == "none" || sel == "off" { @@ -34,6 +34,7 @@ pub fn get(selector: &str) -> Option { } /// The `ID` from /etc/os-release, normalized to a known logo name. +#[cfg(target_os = "linux")] fn detect_distro() -> String { let id = std::fs::read_to_string("/etc/os-release") .ok() @@ -47,7 +48,14 @@ fn detect_distro() -> String { normalize(&id) } +/// macOS is one OS: always the Apple logo. +#[cfg(target_os = "macos")] +fn detect_distro() -> String { + "macos".to_string() +} + /// Map os-release IDs to the logo names we ship. +#[cfg(target_os = "linux")] fn normalize(id: &str) -> String { if id.starts_with("opensuse") { return "opensuse".to_string(); @@ -157,6 +165,10 @@ fn known(name: &str) -> Option { lines: GARUDA, colors: &["31"], }, + "macos" | "apple" | "osx" | "mac" => Logo { + lines: MACOS, + colors: &["32", "33", "91", "31", "35", "34"], + }, "tux" | "linux" | "generic" => Logo { lines: TUX, colors: TUX_COLORS, @@ -656,6 +668,26 @@ const GARUDA: &[&str] = &[ " .d988999889889899dd.", ]; +const MACOS: &[&str] = &[ + " ..'", + " ,xNMM.", + " .OMMMMo", + " lMM\"", + " .;loddo:. .olloddol;.", + " cKMMMMMMMMMMNWMMMMMMMMMM0:", + " $2.KMMMMMMMMMMMMMMMMMMMMMMMWd.", + " XMMMMMMMMMMMMMMMMMMMMMMMX.", + "$3;MMMMMMMMMMMMMMMMMMMMMMMM:", + ":MMMMMMMMMMMMMMMMMMMMMMMM:", + "$4.MMMMMMMMMMMMMMMMMMMMMMMMX.", + " kMMMMMMMMMMMMMMMMMMMMMMMMWd.", + " $5'XMMMMMMMMMMMMMMMMMMMMMMMMMMk", + " 'XMMMMMMMMMMMMMMMMMMMMMMMMK.", + " $6kMMMMMMMMMMMMMMMMMMMMMMd", + " ;KMMMMMMMWXXWMMMMMMMk.", + " \"cooc*\" \"*coo'\"", +]; + const TUX: &[&str] = &[ " $2#####", " $2#######", diff --git a/src/main.rs b/src/main.rs index 7793ff1..d328c7e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,7 +176,7 @@ fn main() { .or_else(|| std::env::var("LOGNAME").ok()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "user".into()); - let host = util::read_trim("/proc/sys/kernel/hostname").unwrap_or_else(|| "localhost".into()); + let host = sys::hostname().unwrap_or_else(|| "localhost".into()); let title_plain = format!("{user}@{host}"); let sep_len = title_plain.chars().count(); @@ -461,7 +461,9 @@ fn print_help() { println!("$PUREFETCH_CONFIG, ~/.config/purefetch/config, or /etc/purefetch/config."); println!(); println!("OPTIONS:"); - println!(" -l, --logo logo: auto (default), a distro name, tux, or none"); + println!( + " -l, --logo logo: auto (default), a distro name, macos, tux, or none" + ); println!(" --logo-file use a custom logo, read verbatim from a file"); println!(" --logo-exec use a custom logo from a command's output (dynamic)"); println!(" --modules comma-separated modules to show ('-' = separator),"); diff --git a/src/sys/darwin.rs b/src/sys/darwin.rs new file mode 100644 index 0000000..d88db50 --- /dev/null +++ b/src/sys/darwin.rs @@ -0,0 +1,445 @@ +//! macOS platform layer — `extern "C"` bindings to libSystem, no libc crate. +//! +//! Raw syscalls are not a stable ABI on macOS: the only supported kernel +//! interface is libSystem, which Rust's std already links unconditionally on +//! darwin targets, so declaring the handful of C functions we need directly +//! keeps the crate at zero external dependencies here too. Struct layouts and +//! constants mirror Apple's SDK headers (xnu: bsd/sys/mount.h, +//! bsd/sys/sysctl.h, mach/vm_statistics.h, libproc.h) and agree with the rust +//! `libc` crate's darwin definitions. +#![allow(dead_code)] + +use super::DiskUsage; +use std::ffi::c_void; +use std::os::raw::c_ulong; + +extern "C" { + fn ioctl(fd: i32, request: c_ulong, ...) -> i32; + fn isatty(fd: i32) -> i32; + fn gethostname(name: *mut u8, len: usize) -> i32; + fn sysctlbyname( + name: *const u8, + oldp: *mut c_void, + oldlenp: *mut usize, + newp: *mut c_void, + newlen: usize, + ) -> i32; + fn mach_host_self() -> u32; + fn host_statistics64(host: u32, flavor: i32, info: *mut i32, count: *mut u32) -> i32; + fn proc_pidinfo(pid: i32, flavor: i32, arg: u64, buffer: *mut c_void, buffersize: i32) -> i32; + // Everywhere but arm64 the plain symbol is the legacy 32-bit-inode + // variant; the $INODE64 suffix selects the modern layout (same cfg rule + // as the libc crate). arm64 only ever had the 64-bit one. + #[cfg_attr(not(target_arch = "aarch64"), link_name = "statfs$INODE64")] + fn statfs(path: *const u8, buf: *mut Statfs) -> i32; +} + +// struct statfs, 64-bit-inode variant (xnu __DARWIN_STRUCT_STATFS64) — 2168 +// bytes, identical layout on arm64 and x86_64. +#[repr(C)] +struct Statfs { + f_bsize: u32, + f_iosize: i32, + f_blocks: u64, + f_bfree: u64, + f_bavail: u64, + f_files: u64, + f_ffree: u64, + f_fsid: [i32; 2], + f_owner: u32, + f_type: u32, + f_flags: u32, + f_fssubtype: u32, + f_fstypename: [u8; 16], + f_mntonname: [u8; 1024], + f_mntfromname: [u8; 1024], + f_flags_ext: u32, + f_reserved: [u32; 7], +} + +/// Disk usage of the filesystem containing `path`, via statfs(2). On APFS +/// every volume shares the container's space, so used = total - available is +/// the honest whole-disk figure (and what fastfetch reports); total - free +/// would hide purgeable/snapshot space. +pub fn disk_usage(path: &str) -> Option { + let mut cpath = Vec::with_capacity(path.len() + 1); + cpath.extend_from_slice(path.as_bytes()); + cpath.push(0); // NUL terminate + let mut sb: Statfs = unsafe { std::mem::zeroed() }; + if unsafe { statfs(cpath.as_ptr(), &mut sb) } != 0 { + return None; + } + let bs = sb.f_bsize as u64; + let total = sb.f_blocks.saturating_mul(bs); + if total == 0 { + return None; + } + let avail = sb.f_bavail.saturating_mul(bs); + Some(DiskUsage { + total, + used: total.saturating_sub(avail), + avail, + }) +} + +#[repr(C)] +struct Winsize { + ws_row: u16, + ws_col: u16, + ws_xpixel: u16, + ws_ypixel: u16, +} + +// _IOR('t', 104, struct winsize): darwin encodes direction and size into the +// request, so the value differs from Linux's. +const TIOCGWINSZ: c_ulong = 0x4008_7468; + +/// Terminal width in columns (stdout), or 80 if it cannot be determined. +pub fn term_width() -> usize { + let mut ws = Winsize { + ws_row: 0, + ws_col: 0, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let r = unsafe { ioctl(1, TIOCGWINSZ, &mut ws as *mut Winsize) }; + if r == 0 && ws.ws_col > 0 { + ws.ws_col as usize + } else { + 80 + } +} + +/// Whether stdout is a terminal (used to decide on color output). +pub fn stdout_is_tty() -> bool { + unsafe { isatty(1) == 1 } +} + +/// The node name, for the `user@host` title. +pub fn hostname() -> Option { + let mut buf = [0u8; 256]; + if unsafe { gethostname(buf.as_mut_ptr(), buf.len()) } != 0 { + return None; + } + buf_str(&buf) +} + +// proc_pidinfo(PROC_PIDTBSDINFO) fills this stable libproc struct — it avoids +// the pointer-laden kinfo_proc layout entirely. +const PROC_PIDTBSDINFO: i32 = 3; + +#[repr(C)] +struct ProcBsdinfo { + pbi_flags: u32, + pbi_status: u32, + pbi_xstatus: u32, + pbi_pid: u32, + pbi_ppid: u32, + pbi_uid: u32, + pbi_gid: u32, + pbi_ruid: u32, + pbi_rgid: u32, + pbi_svuid: u32, + pbi_svgid: u32, + rfu_1: u32, + pbi_comm: [u8; 16], + pbi_name: [u8; 32], + pbi_nfiles: u32, + pbi_pgid: u32, + pbi_pjobc: u32, + e_tdev: u32, + e_tpgid: u32, + pbi_nice: i32, + pbi_start_tvsec: u64, + pbi_start_tvusec: u64, +} + +/// Parent pid and short command name of `pid`, for the parent-chain walks in +/// `detect::{shell,terminal}`. `pbi_name` (32 bytes) beats `pbi_comm`'s +/// 16-byte truncation; the name is empty when neither is readable (the walk +/// just skips it and keeps climbing). +pub fn ppid_comm(pid: u32) -> Option<(u32, String)> { + let mut info: ProcBsdinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as i32; + let r = unsafe { + proc_pidinfo( + pid as i32, + PROC_PIDTBSDINFO, + 0, + &mut info as *mut ProcBsdinfo as *mut c_void, + size, + ) + }; + if r <= 0 { + return None; + } + let comm = buf_str(&info.pbi_name) + .or_else(|| buf_str(&info.pbi_comm)) + .unwrap_or_default(); + Some((info.pbi_ppid, comm)) +} + +/// A NUL-terminated copy of `name` for sysctlbyname. +fn cname(name: &str) -> Vec { + let mut v = Vec::with_capacity(name.len() + 1); + v.extend_from_slice(name.as_bytes()); + v.push(0); + v +} + +/// String from a NUL-terminated (or full) C byte buffer; None if empty. +fn buf_str(buf: &[u8]) -> Option { + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let s = String::from_utf8_lossy(&buf[..end]).trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +/// String sysctl by name (the usual two-call size protocol). +pub fn sysctl_string(name: &str) -> Option { + let n = cname(name); + let mut len = 0usize; + let probe = unsafe { + sysctlbyname( + n.as_ptr(), + std::ptr::null_mut(), + &mut len, + std::ptr::null_mut(), + 0, + ) + }; + if probe != 0 || len == 0 { + return None; + } + let mut buf = vec![0u8; len]; + let r = unsafe { + sysctlbyname( + n.as_ptr(), + buf.as_mut_ptr() as *mut c_void, + &mut len, + std::ptr::null_mut(), + 0, + ) + }; + if r != 0 { + return None; + } + buf.truncate(len); + buf_str(&buf) +} + +/// Integer sysctl by name. Kernel integers come in 4- or 8-byte flavors; both +/// darwin arches are little-endian, so reading either into a zeroed u64 is +/// exact (a 4-byte value fills the low half). +pub fn sysctl_u64(name: &str) -> Option { + let n = cname(name); + let mut val: u64 = 0; + let mut len = std::mem::size_of::(); + let r = unsafe { + sysctlbyname( + n.as_ptr(), + &mut val as *mut u64 as *mut c_void, + &mut len, + std::ptr::null_mut(), + 0, + ) + }; + if r != 0 { + None + } else { + Some(val) + } +} + +// struct timeval on 64-bit darwin: time_t = i64, suseconds_t = i32; repr(C) +// pads it to 16 bytes, which matches the kernel's copy-out size. +#[repr(C)] +struct Timeval { + tv_sec: i64, + tv_usec: i32, +} + +/// Boot time in seconds since the epoch (kern.boottime). +pub fn boottime_secs() -> Option { + let n = cname("kern.boottime"); + let mut tv = Timeval { + tv_sec: 0, + tv_usec: 0, + }; + let mut len = std::mem::size_of::(); + let r = unsafe { + sysctlbyname( + n.as_ptr(), + &mut tv as *mut Timeval as *mut c_void, + &mut len, + std::ptr::null_mut(), + 0, + ) + }; + if r != 0 || tv.tv_sec <= 0 { + None + } else { + Some(tv.tv_sec as u64) + } +} + +// vm.swapusage payload (xnu struct xsw_usage), 32 bytes, no packing. +#[repr(C)] +struct XswUsage { + xsu_total: u64, + xsu_avail: u64, + xsu_used: u64, + xsu_pagesize: u32, + xsu_encrypted: i32, +} + +/// Swap (total, used) in bytes; total is 0 when no swap files exist. +pub fn swap_usage() -> Option<(u64, u64)> { + let n = cname("vm.swapusage"); + let mut xsw: XswUsage = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::(); + let r = unsafe { + sysctlbyname( + n.as_ptr(), + &mut xsw as *mut XswUsage as *mut c_void, + &mut len, + std::ptr::null_mut(), + 0, + ) + }; + if r != 0 { + None + } else { + Some((xsw.xsu_total, xsw.xsu_used)) + } +} + +const HOST_VM_INFO64: i32 = 4; + +// mach vm_statistics64 (xnu mach/vm_statistics.h), full current layout. Only +// the leading fields are consumed; the tail keeps the buffer big enough that +// any kernel revision fills at most what we pass. The u32 fields come in even +// runs, so every u64 already sits on an 8-byte offset and plain repr(C) +// reproduces xnu's natural (aligned(8)) layout exactly. +#[repr(C)] +struct VmStatistics64 { + free_count: u32, + active_count: u32, + inactive_count: u32, + wire_count: u32, + zero_fill_count: u64, + reactivations: u64, + pageins: u64, + pageouts: u64, + faults: u64, + cow_faults: u64, + lookups: u64, + hits: u64, + purges: u64, + purgeable_count: u32, + speculative_count: u32, + decompressions: u64, + compressions: u64, + swapins: u64, + swapouts: u64, + compressor_page_count: u32, + throttled_count: u32, + external_page_count: u32, + internal_page_count: u32, + total_uncompressed_pages_in_compressor: u64, + swapped_count: u64, + total_tag_storage_pages: u64, + nontag_pageable_tag_storage_pages: u64, + nontag_wired_tag_storage_pages: u64, + free_tag_storage_pages: u64, + tag_storing_tag_storage_pages: u64, + total_tagged_pages: u64, + resident_tagged_pages: u64, + compressed_tagged_pages: u64, + tagged_compressions: u64, + tagged_decompressions: u64, + compressed_tag_storage_bytes: u64, + speculative_pages_created: u64, + speculative_pages_activated: u64, + swap_count: u64, + empty_tag_storing_tag_storage_pages: u64, + executable_count: u64, + shared_region_count: u64, + boot_stolen_count: u64, + secluded_count: u64, + active_internal_count: u64, + inactive_internal_count: u64, + active_external_count: u64, + inactive_external_count: u64, + purgeable_pageable_count: u64, + purgeable_wired_count: u64, + background_internal_count: u64, + background_external_count: u64, + donated_count: u64, + realtime_count: u64, + max_mem_count: u64, + phantom_ghosts_found: u64, + phantom_ghosts_added: u64, +} + +/// Memory (used, total) in bytes. Used follows vm_stat / Activity Monitor: +/// total minus really-free pages (free - speculative) and the file cache +/// (external), both of which the kernel reclaims freely. +pub fn memory_used_total() -> Option<(u64, u64)> { + let total = sysctl_u64("hw.memsize").filter(|t| *t > 0)?; + let page = sysctl_u64("hw.pagesize").filter(|p| *p > 0)?; + let mut vs: VmStatistics64 = unsafe { std::mem::zeroed() }; + let mut count = (std::mem::size_of::() / 4) as u32; + let kr = unsafe { + host_statistics64( + mach_host_self(), + HOST_VM_INFO64, + &mut vs as *mut VmStatistics64 as *mut i32, + &mut count, + ) + }; + if kr != 0 { + return None; + } + let reclaimable = (vs.free_count.saturating_sub(vs.speculative_count) as u64) + .saturating_add(vs.external_page_count as u64) + .saturating_mul(page); + Some((total.saturating_sub(reclaimable), total)) +} + +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGGetActiveDisplayList(max: u32, ids: *mut u32, count: *mut u32) -> i32; + fn CGDisplayCopyDisplayMode(display: u32) -> *mut c_void; + fn CGDisplayModeGetPixelWidth(mode: *mut c_void) -> usize; + fn CGDisplayModeGetPixelHeight(mode: *mut c_void) -> usize; + fn CGDisplayModeRelease(mode: *mut c_void); +} + +/// Native pixel size of each active display. A headless/ssh session gets a +/// success with zero displays, so this is safe to call unconditionally. +pub fn displays() -> Vec<(usize, usize)> { + const MAX: usize = 16; + let mut ids = [0u32; MAX]; + let mut n: u32 = 0; + if unsafe { CGGetActiveDisplayList(MAX as u32, ids.as_mut_ptr(), &mut n) } != 0 { + return Vec::new(); + } + let mut out = Vec::new(); + for &id in ids.iter().take(n as usize) { + let mode = unsafe { CGDisplayCopyDisplayMode(id) }; + if mode.is_null() { + continue; + } + let w = unsafe { CGDisplayModeGetPixelWidth(mode) }; + let h = unsafe { CGDisplayModeGetPixelHeight(mode) }; + unsafe { CGDisplayModeRelease(mode) }; + if w > 0 && h > 0 { + out.push((w, h)); + } + } + out +} diff --git a/src/sys.rs b/src/sys/linux.rs similarity index 85% rename from src/sys.rs rename to src/sys/linux.rs index dccfac4..36605bf 100644 --- a/src/sys.rs +++ b/src/sys/linux.rs @@ -1,4 +1,4 @@ -//! Minimal syscall shims — no libc, no external crates. +//! Linux platform layer — minimal syscall shims, no libc, no external crates. //! //! The whole point of purefetch is to be written *entirely* in Rust, so instead //! of binding to C's `statvfs`/`ioctl` we issue the raw Linux syscalls directly @@ -9,6 +9,8 @@ //! still builds. #![allow(dead_code)] +use super::DiskUsage; + #[cfg(target_arch = "x86_64")] #[inline] unsafe fn syscall3(n: usize, a1: usize, a2: usize, a3: usize) -> isize { @@ -139,12 +141,6 @@ struct Statfs { f_spare: [i64; 4], } -pub struct DiskUsage { - pub total: u64, - pub used: u64, - pub avail: u64, -} - /// Disk usage of the filesystem containing `path`, via the `statfs(2)` syscall. pub fn disk_usage(path: &str) -> Option { let mut cpath = Vec::with_capacity(path.len() + 1); @@ -220,3 +216,21 @@ pub fn stdout_is_tty() -> bool { let mut buf = [0u8; 64]; unsafe { syscall3(SYS_IOCTL, 1, TCGETS, buf.as_mut_ptr() as usize) >= 0 } } + +/// The node name, for the `user@host` title. +pub fn hostname() -> Option { + crate::util::read_trim("/proc/sys/kernel/hostname") +} + +/// Parent pid and short command name of `pid`, for the parent-chain walks in +/// `detect::{shell,terminal}`. The ppid comes from `/proc//stat`; the +/// process name there can contain spaces/parens, so split after the LAST ')' — +/// the remainder is "state ppid pgrp ...", ppid being the 2nd field. `comm` is +/// empty when unreadable (the walk just skips it and keeps climbing). +pub fn ppid_comm(pid: u32) -> Option<(u32, String)> { + let stat = crate::util::read_trim(&format!("/proc/{pid}/stat"))?; + let after = &stat[stat.rfind(')')? + 1..]; + let ppid: u32 = after.split_whitespace().nth(1)?.parse().ok()?; + let comm = crate::util::read_trim(&format!("/proc/{pid}/comm")).unwrap_or_default(); + Some((ppid, comm)) +} diff --git a/src/sys/mod.rs b/src/sys/mod.rs new file mode 100644 index 0000000..f887a97 --- /dev/null +++ b/src/sys/mod.rs @@ -0,0 +1,33 @@ +//! Platform layer — everything OS-specific lives in one per-OS submodule. +//! +//! The rest of the crate only sees this module's re-exported API: +//! * `disk_usage(path)` — statfs on the filesystem containing `path` +//! * `term_width()` — terminal width in columns (80 fallback) +//! * `stdout_is_tty()` — color/width gate +//! * `hostname()` — the node name for the `user@host` title +//! * `ppid_comm(pid)` — (parent pid, short command name), for the parent-chain +//! walks in `detect::{shell,terminal}` +//! +//! Linux issues raw syscalls and reads `/proc` (no libc, no external crates); +//! macOS binds `extern "C"` to libSystem — the only stable syscall ABI there — +//! which std links unconditionally, so the crate stays free of external +//! dependencies on both. + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "linux")] +pub use linux::*; + +#[cfg(target_os = "macos")] +mod darwin; +#[cfg(target_os = "macos")] +pub use darwin::*; + +// `avail` is unused today but is part of the statfs contract both platform +// layers fill in; keep it rather than lose the information. +#[allow(dead_code)] +pub struct DiskUsage { + pub total: u64, + pub used: u64, + pub avail: u64, +} diff --git a/src/util.rs b/src/util.rs index 5d43686..1c3c7c8 100644 --- a/src/util.rs +++ b/src/util.rs @@ -85,9 +85,70 @@ pub fn percent(part: u64, total: u64) -> u64 { } } +/// The raw value of the first `"key" = ...` property in an `ioreg` text dump. +/// The lines carry tree decoration (`| `), so the needle is searched anywhere; +/// the leading quote in it keeps longer key names from matching. +/// macOS-only data source (IORegistry); the parsers are OS-neutral for tests. +#[cfg(any(target_os = "macos", test))] +pub fn ioreg_find(dump: &str, key: &str) -> Option { + let needle = format!("\"{key}\" = "); + dump.lines().find_map(|l| { + l.find(&needle) + .map(|pos| l[pos + needle.len()..].to_string()) + }) +} + +/// Decode one ioreg property value into a string. ioreg prints CFString as +/// `"str"`, printable CFData as `<"str">`, and binary CFData as `` +/// (often a NUL-terminated ASCII string, e.g. the `model` of a GPU). +#[cfg(any(target_os = "macos", test))] +pub fn ioreg_decode(raw: &str) -> Option { + let raw = raw.trim(); + if let Some(inner) = raw + .strip_prefix("<\"") + .and_then(|r| r.strip_suffix("\">")) + .or_else(|| raw.strip_prefix('"').and_then(|r| r.strip_suffix('"'))) + { + return non_empty(inner); + } + let hex = raw.strip_prefix('<').and_then(|r| r.strip_suffix('>'))?; + let hex: String = hex.chars().filter(|c| !c.is_whitespace()).collect(); + if hex.is_empty() || hex.len() % 2 != 0 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let bytes: Vec = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0)) + .collect(); + let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); + non_empty(&String::from_utf8_lossy(&bytes[..end])) +} + +/// A string ioreg property, found and decoded. +#[cfg(any(target_os = "macos", test))] +pub fn ioreg_string(dump: &str, key: &str) -> Option { + ioreg_decode(&ioreg_find(dump, key)?) +} + +/// An integer ioreg property (printed bare, e.g. `"gpu-core-count" = 8`). +#[cfg(any(target_os = "macos", test))] +pub fn ioreg_u64(dump: &str, key: &str) -> Option { + ioreg_find(dump, key)?.trim().parse().ok() +} + +#[cfg(any(target_os = "macos", test))] +fn non_empty(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} + #[cfg(test)] mod tests { - use super::{human_iec, percent}; + use super::{human_iec, ioreg_string, ioreg_u64, percent}; #[test] fn iec_units_and_rounding() { @@ -108,4 +169,31 @@ mod tests { assert_eq!(percent(2, 3), 67); assert_eq!(percent(3, 3), 100); } + + #[test] + fn ioreg_values_decode_all_three_shapes() { + let dump = "\ ++-o AGXAcceleratorG13G + | \"model\" = <\"Apple M1\"> + | \"gpu-core-count\" = 8 + | \"board-id\" = <4d61636d696e69392c3100> + | \"IOClass\" = \"AGXAcceleratorG13G\" +"; + // Printable CFData: <"str">. + assert_eq!(ioreg_string(dump, "model").as_deref(), Some("Apple M1")); + // Hex CFData holding a NUL-terminated ASCII string. + assert_eq!( + ioreg_string(dump, "board-id").as_deref(), + Some("Macmini9,1") + ); + // Plain CFString. + assert_eq!( + ioreg_string(dump, "IOClass").as_deref(), + Some("AGXAcceleratorG13G") + ); + // Bare integer, and a missing key. + assert_eq!(ioreg_u64(dump, "gpu-core-count"), Some(8)); + assert_eq!(ioreg_u64(dump, "model"), None); + assert_eq!(ioreg_string(dump, "absent"), None); + } }