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 · 2026-05-20T18:09:46Z (hace 5 meses) · padre cf337c88d · ver el árbol en este commit
Delta semántico · 32
crates/modules/shuma/shuma-sysmon/src/lib.rs
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));
+ }
+}