eger 1.2.0

A high-level, parallel ASCII-art media pipeline built on top of iascii: images, video, GIFs, dithering, animated text/banners, and procedural illusions, all rendered to ANSI.
Documentation
//! System-aware resource scaling: detect what the machine has, size every
//! pool/buffer/chunk from it, and stay conservative whenever detection or
//! the numbers look wrong.
//!
//! The policy, in one place:
//!
//! - **CPUs** drive worker-thread counts
//!   ([`ConfigBuilder::num_threads`](crate::config::ConfigBuilder::num_threads)
//!   via [`cpu_count`]. Detection failure falls back to 1 thread — slow but
//!   never oversubscribed.
//! - **RAM** drives decode-chunk buffering
//!   ([`ConfigBuilder::decode_chunk_bytes`](crate::config::ConfigBuilder::decode_chunk_bytes))
//!   via [`default_decode_chunk_bytes`]: roughly 1/32 of total RAM, clamped
//!   to 32 MiB – 512 MiB. An 8 GB laptop gets 256 MiB in flight (about two
//!   chunks of 4K raw frames plus conversion headroom); a 4 GB machine
//!   automatically drops to 128 MiB; a 128 GB workstation still caps at
//!   512 MiB rather than growing without bound. Detection failure assumes a
//!   4 GB machine — always the safer side.
//! - **Manual overrides always win.** If you set
//!   [`ConfigBuilder::num_threads`](crate::config::ConfigBuilder::num_threads)
//!   or [`ConfigBuilder::decode_chunk_bytes`](crate::config::ConfigBuilder::decode_chunk_bytes),
//!   detection is bypassed for that
//!   knob (a `0` chunk cap degrades to one frame per chunk, never to a
//!   hang or a panic).
//!
//! Game developers can reuse this directly: query [`SystemSpecs::detect`]
//! once at startup and pick grid size, [`Style`](crate::style::Style), and
//! frame rate from what the player's machine actually has.

/// What the machine offers: logical CPUs and total RAM. Cheap to query —
/// call [`SystemSpecs::detect`] once and reuse it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SystemSpecs {
    /// Logical CPU count (used to size Rayon pools).
    pub cpus: usize,
    /// Total physical RAM in bytes (used to bound decode buffering).
    pub total_ram_bytes: u64,
}

impl SystemSpecs {
    /// Detects specs with conservative fallbacks: unknown CPUs → 1 thread,
    /// unknown RAM → assume 4 GiB. Never returns zeros.
    pub fn detect() -> Self {
        Self {
            cpus: cpu_count(),
            total_ram_bytes: total_ram_bytes(),
        }
    }
}

/// Logical CPU count, or 1 when detection fails. Single source of truth
/// for every thread-pool default in the crate.
pub fn cpu_count() -> usize {
    std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or(1)
        .max(1)
}

/// Total physical RAM in bytes, or 4 GiB when detection fails (the safer
/// side: under-buffer rather than OOM a machine we couldn't measure).
pub fn total_ram_bytes() -> u64 {
    detect_ram_bytes().unwrap_or(4 * 1024 * 1024 * 1024)
}

#[cfg(target_os = "linux")]
fn detect_ram_bytes() -> Option<u64> {
    // /proc/meminfo line: "MemTotal:       16384000 kB".
    let text = std::fs::read_to_string("/proc/meminfo").ok()?;
    parse_meminfo_kb(&text).map(|kb| kb.saturating_mul(1024))
}

/// Parses `MemTotal` (kB) out of `/proc/meminfo` text. Split out for
/// testing — the filesystem read stays in [`detect_ram_bytes`].
#[cfg(target_os = "linux")]
fn parse_meminfo_kb(text: &str) -> Option<u64> {
    for line in text.lines() {
        // Non-matching lines are skipped, not aborted: `MemTotal` is
        // normally first in /proc/meminfo but nothing guarantees it.
        let Some(rest) = line.strip_prefix("MemTotal:") else {
            continue;
        };
        let rest = rest.trim();
        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
        if digits.is_empty() {
            return None;
        }
        return digits.parse::<u64>().ok();
    }
    None
}

#[cfg(target_os = "macos")]
fn detect_ram_bytes() -> Option<u64> {
    let out = std::process::Command::new("sysctl")
        .arg("-n")
        .arg("hw.memsize")
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    String::from_utf8_lossy(&out.stdout)
        .trim()
        .parse::<u64>()
        .ok()
}

#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn detect_ram_bytes() -> Option<u64> {
    // Windows and other targets: no dependency-free probe wired up yet.
    // Fall through to the conservative 4 GiB default rather than guessing.
    None
}

/// Lower bound for an automatically derived chunk cap: small enough to be
/// harmless on 2 GB machines, large enough to hold one 4K raw frame
/// (3840x2160x3 ≈ 25 MiB) with headroom.
pub const MIN_DECODE_CHUNK_BYTES: usize = 32 * 1024 * 1024;
/// Upper bound: even on huge machines, in-flight decode buffering never
/// exceeds this — conversion and rendering have their own transient costs
/// on top, and unbounded growth buys nothing past a few chunks.
pub const MAX_DECODE_CHUNK_BYTES: usize = 512 * 1024 * 1024;

/// Derives the per-chunk raw-byte cap from specs: `total_ram / 32`,
/// clamped to [`MIN_DECODE_CHUNK_BYTES`]..=[`MAX_DECODE_CHUNK_BYTES`].
/// 8 GiB → 256 MiB, 4 GiB → 128 MiB, ≥16 GiB → 512 MiB.
pub fn default_decode_chunk_bytes(specs: SystemSpecs) -> usize {
    let want = specs.total_ram_bytes / 32;
    want.clamp(MIN_DECODE_CHUNK_BYTES as u64, MAX_DECODE_CHUNK_BYTES as u64) as usize
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detect_never_returns_zeros() {
        let specs = SystemSpecs::detect();
        assert!(specs.cpus >= 1);
        assert!(specs.total_ram_bytes >= 1024 * 1024 * 1024);
    }

    #[test]
    fn chunk_cap_scales_with_ram_and_stays_bounded() {
        let gb = |n: u64| SystemSpecs {
            cpus: 8,
            total_ram_bytes: n * 1024 * 1024 * 1024,
        };
        assert_eq!(default_decode_chunk_bytes(gb(8)), 256 * 1024 * 1024);
        assert_eq!(default_decode_chunk_bytes(gb(4)), 128 * 1024 * 1024);
        // Huge machines cap out instead of growing forever.
        assert_eq!(default_decode_chunk_bytes(gb(128)), MAX_DECODE_CHUNK_BYTES);
        // Tiny machines floor out instead of collapsing to nothing.
        assert_eq!(
            default_decode_chunk_bytes(SystemSpecs {
                cpus: 2,
                total_ram_bytes: 512 * 1024 * 1024,
            }),
            MIN_DECODE_CHUNK_BYTES
        );
        // Zero RAM (shouldn't happen via detect, but be safe anyway).
        assert_eq!(
            default_decode_chunk_bytes(SystemSpecs {
                cpus: 2,
                total_ram_bytes: 0,
            }),
            MIN_DECODE_CHUNK_BYTES
        );
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn meminfo_parser_reads_memtotal_kb() {
        let sample = "MemTotal:       16384000 kB\nMemFree:         12345 kB\n";
        assert_eq!(parse_meminfo_kb(sample), Some(16384000));
        // MemTotal on a later line must still be found (real-world ordering
        // is MemTotal-first, but the parser must not depend on it).
        let reordered = "MemFree: 12345 kB\nSwapTotal: 0 kB\nMemTotal: 8000000 kB\n";
        assert_eq!(parse_meminfo_kb(reordered), Some(8000000));
        assert_eq!(parse_meminfo_kb("MemFree: 1 kB\n"), None);
        assert_eq!(parse_meminfo_kb("MemTotal: kB\n"), None);
        assert_eq!(parse_meminfo_kb(""), None);
    }

    #[cfg(target_os = "linux")]
    #[test]
    fn live_meminfo_agrees_with_detection() {
        // Sanity: on Linux the real file must parse to a plausible size.
        let specs = SystemSpecs::detect();
        assert!(specs.total_ram_bytes >= 512 * 1024 * 1024);
    }
}