Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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
24 changes: 17 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <triple>` and,
ideally, a run under `qemu-<arch>`.

## 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 `<area>: <summary>` subject line.
Expand Down
49 changes: 32 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center"><img src="assets/purefetch.svg" alt="purefetch running on Debian" width="680"></p>

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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 (<pool>)`), not just the root
dataset's few GiB.
- **Process-parent detection.** `Shell` and `Terminal` walk the `/proc/<pid>`
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/<pid>/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.
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions assets/logos/macos.txt
Original file line number Diff line number Diff line change
@@ -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'"
13 changes: 11 additions & 2 deletions examples/genlogos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const ORDER: &[(&str, &[&str])] = &[
("devuan", &["devuan"]),
("mx", &["mx"]),
("garuda", &["garuda"]),
("macos", &["macos", "apple", "osx", "mac"]),
("tux", &["tux", "linux", "generic"]),
];

Expand Down Expand Up @@ -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<Logo> {
let sel = selector.to_ascii_lowercase();
if sel == "none" || sel == "off" {
Expand All @@ -184,6 +185,7 @@ pub fn get(selector: &str) -> Option<Logo> {
}

/// 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()
Expand All @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,7 +33,7 @@
asl20
];
mainProgram = "purefetch";
platforms = pkgs.lib.platforms.linux;
platforms = pkgs.lib.platforms.linux ++ pkgs.lib.platforms.darwin;
};
};
default = purefetch;
Expand Down
80 changes: 77 additions & 3 deletions src/detect/battery.rs
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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());
}
}
Loading
Loading