gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definiciones

feat(shuma): shuma-sysmon — muestreo de CPU/memoria con historial

Lee /proc/stat y /proc/meminfo; calcula uso de CPU (delta entre
muestras) y de memoria; mantiene un History circular para la curva
del monitor. Parseo puro (parse_cpu_stat/parse_meminfo) separado de
la lectura de archivos → testeable sin tocar el sistema.

8 tests. #![forbid(unsafe_code)], cero deps de UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

7c38a8af4e643581727f6e5885ea45d597ebecbb · por sergio · (hace 5 meses) · padre cf337c88d · ver el árbol en este commit

Delta semántico · 32

crates/modules/shuma/shuma-sysmon/src/lib.rs

4 archivos · +316 −0

diff --git a/Cargo.lock b/Cargo.lock
index da3488e..adef161 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -11439,6 +11439,13 @@ dependencies = [
  "shuma-intent",
 ]
 
+[[package]]
+name = "shuma-sysmon"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "signal-hook-registry"
 version = "1.4.8"
diff --git a/Cargo.toml b/Cargo.toml
index ecda18a..540c2af 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -181,6 +181,7 @@ members = [
     "crates/modules/shuma/shuma-core",
     "crates/modules/shuma/shuma-intent",
     "crates/modules/shuma/shuma-line",
+    "crates/modules/shuma/shuma-sysmon",
     "crates/modules/shuma/shuma-shell-render",
 
     # ============================================================
diff --git a/crates/modules/shuma/shuma-sysmon/Cargo.toml b/crates/modules/shuma/shuma-sysmon/Cargo.toml
new file mode 100644
index 0000000..eed9c37
--- /dev/null
+++ b/crates/modules/shuma/shuma-sysmon/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "shuma-sysmon"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "shuma — muestreo de CPU y memoria con historial para los monitores del shell. Parseo de /proc separado del cálculo, agnóstico de UI."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/crates/modules/shuma/shuma-sysmon/src/lib.rs b/crates/modules/shuma/shuma-sysmon/src/lib.rs
new file mode 100644
index 0000000..4b518f7
--- /dev/null
+++ b/crates/modules/shuma/shuma-sysmon/src/lib.rs
@@ -0,0 +1,297 @@
+//! `shuma-sysmon` — muestreo de CPU y memoria para los monitores del shell.
+//!
+//! Lee `/proc/stat` y `/proc/meminfo`, calcula el porcentaje de uso de
+//! CPU (delta entre dos muestras) y de memoria, y mantiene un historial
+//! corto para dibujar la curva del monitor.
+//!
+//! El parseo de `/proc` está separado del cálculo: las funciones puras
+//! [`parse_cpu_stat`] y [`parse_meminfo`] se prueban con texto fijo, y
+//! [`SystemSampler::sample`] sólo añade la lectura de archivos. Así la
+//! lógica es testeable sin depender del sistema y el crate es agnóstico
+//! de cualquier frontend.
+
+#![forbid(unsafe_code)]
+
+use std::collections::VecDeque;
+
+use serde::{Deserialize, Serialize};
+
+/// Acumuladores de CPU de `/proc/stat` — tiempo ocupado y total.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct CpuStat {
+    pub busy: u64,
+    pub total: u64,
+}
+
+/// Memoria de `/proc/meminfo`, en kibibytes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MemStat {
+    pub total_kb: u64,
+    pub available_kb: u64,
+}
+
+/// Parsea la línea agregada `cpu` de `/proc/stat`. El uso instantáneo no
+/// se puede sacar de una sola muestra — hace falta el delta entre dos.
+pub fn parse_cpu_stat(text: &str) -> Option<CpuStat> {
+    let line = text.lines().find(|l| {
+        l.starts_with("cpu") && l[3..].starts_with(char::is_whitespace)
+    })?;
+    let fields: Vec<u64> = line
+        .split_whitespace()
+        .skip(1) // la etiqueta "cpu"
+        .filter_map(|f| f.parse().ok())
+        .collect();
+    if fields.len() < 4 {
+        return None;
+    }
+    // Campos: user nice system idle iowait irq softirq steal …
+    let total: u64 = fields.iter().sum();
+    let idle = fields[3] + fields.get(4).copied().unwrap_or(0); // idle + iowait
+    Some(CpuStat { busy: total.saturating_sub(idle), total })
+}
+
+/// Parsea `MemTotal` y `MemAvailable` de `/proc/meminfo`.
+pub fn parse_meminfo(text: &str) -> Option<MemStat> {
+    let field = |key: &str| -> Option<u64> {
+        text.lines()
+            .find(|l| l.starts_with(key))?
+            .split_whitespace()
+            .nth(1)?
+            .parse()
+            .ok()
+    };
+    Some(MemStat {
+        total_kb: field("MemTotal:")?,
+        available_kb: field("MemAvailable:")?,
+    })
+}
+
+/// Un historial circular de valores `f32` para dibujar una curva.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct History {
+    samples: VecDeque<f32>,
+    capacity: usize,
+}
+
+impl History {
+    /// Historial vacío con capacidad para `capacity` muestras.
+    pub fn new(capacity: usize) -> Self {
+        Self { samples: VecDeque::with_capacity(capacity.max(1)), capacity: capacity.max(1) }
+    }
+
+    /// Añade una muestra; descarta la más antigua si se llena.
+    pub fn push(&mut self, value: f32) {
+        if self.samples.len() == self.capacity {
+            self.samples.pop_front();
+        }
+        self.samples.push_back(value);
+    }
+
+    /// Muestras de la más antigua a la más reciente.
+    pub fn values(&self) -> Vec<f32> {
+        self.samples.iter().copied().collect()
+    }
+
+    /// Muestra más reciente.
+    pub fn last(&self) -> Option<f32> {
+        self.samples.back().copied()
+    }
+
+    pub fn len(&self) -> usize {
+        self.samples.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.samples.is_empty()
+    }
+
+    pub fn capacity(&self) -> usize {
+        self.capacity
+    }
+}
+
+/// Una lectura del estado del sistema en un instante.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct Snapshot {
+    /// Uso de CPU, `0.0..=100.0`.
+    pub cpu_percent: f32,
+    /// Uso de memoria, `0.0..=100.0`.
+    pub mem_percent: f32,
+    pub mem_used_mb: u64,
+    pub mem_total_mb: u64,
+    /// `false` si no se pudo leer `/proc` (p. ej. fuera de Linux).
+    pub valid: bool,
+}
+
+impl Snapshot {
+    fn invalid() -> Self {
+        Self { cpu_percent: 0.0, mem_percent: 0.0, mem_used_mb: 0, mem_total_mb: 0, valid: false }
+    }
+}
+
+/// Muestreador del sistema: guarda la muestra de CPU anterior (para el
+/// delta) y el historial de ambas curvas.
+#[derive(Debug, Clone)]
+pub struct SystemSampler {
+    prev_cpu: Option<CpuStat>,
+    cpu_history: History,
+    mem_history: History,
+}
+
+impl SystemSampler {
+    /// Crea un muestreador cuyas curvas guardan `history` muestras.
+    pub fn new(history: usize) -> Self {
+        Self {
+            prev_cpu: None,
+            cpu_history: History::new(history),
+            mem_history: History::new(history),
+        }
+    }
+
+    /// Calcula un `Snapshot` a partir del texto de `/proc/stat` y
+    /// `/proc/meminfo`. Es la parte pura — `sample` sólo le añade la
+    /// lectura de archivos.
+    pub fn sample_from(&mut self, stat_text: &str, meminfo_text: &str) -> Snapshot {
+        let (Some(cpu), Some(mem)) =
+            (parse_cpu_stat(stat_text), parse_meminfo(meminfo_text))
+        else {
+            return Snapshot::invalid();
+        };
+
+        // El uso de CPU es el delta de ocupación entre dos muestras.
+        let cpu_percent = match self.prev_cpu {
+            Some(prev) => {
+                let total_delta = cpu.total.saturating_sub(prev.total);
+                let busy_delta = cpu.busy.saturating_sub(prev.busy);
+                if total_delta == 0 {
+                    self.cpu_history.last().unwrap_or(0.0)
+                } else {
+                    (busy_delta as f32 / total_delta as f32 * 100.0).clamp(0.0, 100.0)
+                }
+            }
+            None => 0.0, // primera muestra: aún no hay delta
+        };
+        self.prev_cpu = Some(cpu);
+
+        let used_kb = mem.total_kb.saturating_sub(mem.available_kb);
+        let mem_percent = if mem.total_kb == 0 {
+            0.0
+        } else {
+            (used_kb as f32 / mem.total_kb as f32 * 100.0).clamp(0.0, 100.0)
+        };
+
+        self.cpu_history.push(cpu_percent);
+        self.mem_history.push(mem_percent);
+
+        Snapshot {
+            cpu_percent,
+            mem_percent,
+            mem_used_mb: used_kb / 1024,
+            mem_total_mb: mem.total_kb / 1024,
+            valid: true,
+        }
+    }
+
+    /// Lee `/proc` y produce un `Snapshot`. Fuera de Linux, o si `/proc`
+    /// no está disponible, devuelve un snapshot `valid: false`.
+    pub fn sample(&mut self) -> Snapshot {
+        let stat = std::fs::read_to_string("/proc/stat");
+        let meminfo = std::fs::read_to_string("/proc/meminfo");
+        match (stat, meminfo) {
+            (Ok(s), Ok(m)) => self.sample_from(&s, &m),
+            _ => Snapshot::invalid(),
+        }
+    }
+
+    /// Historial de uso de CPU (curva del monitor).
+    pub fn cpu_history(&self) -> &History {
+        &self.cpu_history
+    }
+
+    /// Historial de uso de memoria.
+    pub fn mem_history(&self) -> &History {
+        &self.mem_history
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const STAT_1: &str = "cpu  100 0 50 800 50 0 0 0 0 0\ncpu0 50 0 25 400 25 0 0\n";
+    // 100 jiffies de ocupación más que STAT_1, 100 de inactividad más.
+    const STAT_2: &str = "cpu  150 0 100 850 100 0 0 0 0 0\ncpu0 75 0 50 425 50 0 0\n";
+    const MEMINFO: &str =
+        "MemTotal:       16000000 kB\nMemFree: 2000000 kB\nMemAvailable:    4000000 kB\n";
+
+    #[test]
+    fn parses_cpu_aggregate_line() {
+        let c = parse_cpu_stat(STAT_1).unwrap();
+        // total = 100+0+50+800+50 = 1000; idle = 800+50 = 850; busy = 150.
+        assert_eq!(c.total, 1000);
+        assert_eq!(c.busy, 150);
+    }
+
+    #[test]
+    fn parses_meminfo() {
+        let m = parse_meminfo(MEMINFO).unwrap();
+        assert_eq!(m.total_kb, 16_000_000);
+        assert_eq!(m.available_kb, 4_000_000);
+    }
+
+    #[test]
+    fn rejects_malformed_proc_text() {
+        assert!(parse_cpu_stat("garbage").is_none());
+        assert!(parse_meminfo("MemTotal: only").is_none());
+    }
+
+    #[test]
+    fn first_sample_has_zero_cpu_then_delta() {
+        let mut s = SystemSampler::new(60);
+        let first = s.sample_from(STAT_1, MEMINFO);
+        assert_eq!(first.cpu_percent, 0.0); // sin muestra previa
+        assert!(first.valid);
+
+        let second = s.sample_from(STAT_2, MEMINFO);
+        // total_delta: 1200-1000=200; busy_delta: STAT_2 busy = 150+0+100=250
+        // total2 = 150+0+100+850+100 = 1200; idle2 = 950; busy2 = 250.
+        // busy_delta = 250-150 = 100; cpu% = 100/200 = 50%.
+        assert!((second.cpu_percent - 50.0).abs() < 0.01);
+    }
+
+    #[test]
+    fn memory_percent_uses_available() {
+        let mut s = SystemSampler::new(60);
+        let snap = s.sample_from(STAT_1, MEMINFO);
+        // used = 16000000 - 4000000 = 12000000 kB → 75%.
+        assert!((snap.mem_percent - 75.0).abs() < 0.01);
+        assert_eq!(snap.mem_total_mb, 16_000_000 / 1024);
+    }
+
+    #[test]
+    fn invalid_proc_yields_invalid_snapshot() {
+        let mut s = SystemSampler::new(60);
+        let snap = s.sample_from("nonsense", "nonsense");
+        assert!(!snap.valid);
+    }
+
+    #[test]
+    fn history_fills_both_curves() {
+        let mut s = SystemSampler::new(60);
+        s.sample_from(STAT_1, MEMINFO);
+        s.sample_from(STAT_2, MEMINFO);
+        assert_eq!(s.cpu_history().len(), 2);
+        assert_eq!(s.mem_history().len(), 2);
+    }
+
+    #[test]
+    fn history_is_a_bounded_ring() {
+        let mut h = History::new(3);
+        for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
+            h.push(v);
+        }
+        assert_eq!(h.len(), 3);
+        assert_eq!(h.values(), vec![3.0, 4.0, 5.0]); // las 3 más recientes
+        assert_eq!(h.last(), Some(5.0));
+    }
+}