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 { + let line = text.lines().find(|l| { + l.starts_with("cpu") && l[3..].starts_with(char::is_whitespace) + })?; + let fields: Vec = 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 { + let field = |key: &str| -> Option { + 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, + 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 { + self.samples.iter().copied().collect() + } + + /// Muestra más reciente. + pub fn last(&self) -> Option { + 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, + 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)); + } +}