feat: shuma standalone — shell interactiva sobre Llimphi (front-door, git-dep al monorepo)

Shell con paridad zsh/fish, multiplexado y sesiones remotas nativas, en chasis
Llimphi de 4 slots. Front-door limpio: solo crates shuma-*/matilda-*; Llimphi y
lo fundacional (chasqui/minga discovery, pata-host, arje, hojas shared) por
git-dep del monorepo gioser.git — cero vendoring. shuma-discern se trae del
monorepo (lo necesita chasqui, evita doble-fuente). cargo check pasa (0 errores).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:35:41 +00:00
commit 1e5d3ed239
182 changed files with 42292 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
+72
View File
@@ -0,0 +1,72 @@
# shuma
> Shell interactivo con paridad zsh/fish, sobre chasis Llimphi.
`shuma` reemplaza zsh + tmux + mosh con una sola pieza: shell con history/completion/job-control, multiplexing nativo (no `tmux`), sesiones remotas (no `mosh`), todo dentro de un chasis Llimphi de 4 slots (TopBar, Main, BottomBar, DrawerTab + drawer Quake). Roadmap de 8 bloques (target 2026-05-25). `matilda` es la herramienta hermana para configuración declarativa multi-host.
## Instalación
```sh
# shell desktop
cargo run --release -p shuma-shell-llimphi
# CLI puro
cargo run --release -p shuma-cli
# daemon (multi-sesión persistente)
cargo run --release -p shuma-daemon
```
## Compatibilidad
- **Linux / macOS / Windows** — shell + UI Llimphi.
- **Wawa** — corre adentro del kernel.
- Protocolo `shuma-protocol` permite cliente local + server remoto sin SSH.
## Crates: shuma
| Crate | Rol |
|---|---|
| [`shuma-core`](shuma-core/README.md) | Tipos: Session, Command, Output. |
| [`shuma-cli`](shuma-cli/README.md) | CLI (no Llimphi). |
| [`shuma-daemon`](shuma-daemon/README.md) | Daemon de sesiones. |
| [`shuma-shell-llimphi`](shuma-shell-llimphi/README.md) | Shell con UI Llimphi. |
| [`shuma-shell-render`](shuma-shell-render/README.md) | Renderer de output (ANSI, imágenes, links). |
| [`shuma-protocol`](shuma-protocol/README.md) | Protocolo wire (reemplazo de SSH/mosh). |
| [`shuma-gateway`](shuma-gateway/README.md) | Gateway de sesiones remotas. |
| [`shuma-remote-exec`](shuma-remote-exec/README.md) | Exec remoto vía gateway. |
| [`shuma-session`](shuma-session/README.md) | Sesión persistente. |
| [`shuma-history`](shuma-history/README.md) | History con búsqueda fuzzy. |
| [`shuma-exec`](shuma-exec/README.md) | Ejecutor de comandos. |
| [`shuma-line`](shuma-line/README.md) | Readline (edición · completion · highlight). |
| [`shuma-config`](shuma-config/README.md) | Config del shell. |
| [`shuma-intent`](shuma-intent/README.md) | Intent → comando (predictor). |
| [`shuma-infer`](shuma-infer/README.md) | Inferencia para `intent`. |
| [`shuma-discern`](shuma-discern/README.md) | Discriminador comando-vs-texto. |
| [`shuma-link`](shuma-link/README.md) | Links clickables en output. |
| [`shuma-sysmon`](shuma-sysmon/README.md) | Monitor de sistema embebido. |
| [`shuma-card`](shuma-card/README.md) | Card escritorio. |
| [`shuma-module`](shuma-module/README.md) | Trait módulo del chasis. |
| [`shuma-module-shell`](shuma-module-shell/README.md) | Módulo shell (Main slot). |
| [`shuma-module-commandbar`](shuma-module-commandbar/README.md) | Módulo command bar (TopBar). |
| [`shuma-module-launcher`](shuma-module-launcher/README.md) | Módulo launcher (DrawerTab). |
| [`shuma-module-matilda`](shuma-module-matilda/README.md) | Módulo matilda integrado. |
## Crates: matilda (declarative host config)
| Crate | Rol |
|---|---|
| [`matilda-core`](matilda/matilda-core/README.md) | Modelo de config declarativa. |
| [`matilda-config`](matilda/matilda-config/README.md) | Loader de archivos. |
| [`matilda-plan`](matilda/matilda-plan/README.md) | Planificador de diff (estado actual → deseado). |
| [`matilda-apply`](matilda/matilda-apply/README.md) | Ejecutor del plan. |
| [`matilda-discover`](matilda/matilda-discover/README.md) | Descubrimiento de estado actual. |
| [`matilda-linker`](matilda/matilda-linker/README.md) | Enlaza dotfiles. |
| [`matilda-ghost`](matilda/matilda-ghost/README.md) | Modo dry-run. |
| [`matilda-app`](matilda/matilda-app/README.md) | CLI/UI. |
## Consideraciones
- **Reemplazo, no añadido.** Si usás shuma, podés desinstalar zsh/tmux/mosh; todo el comportamiento está cubierto.
- **`intent → comando`** es opcional; sin LLM corre el shell tradicional sin diferencia.
- Sesiones remotas usan **`shuma-protocol`** sobre TCP/TLS — no requiere demonio SSH.
+27
View File
@@ -0,0 +1,27 @@
# shuma
> Interactive shell with zsh/fish parity, on a Llimphi chassis.
`shuma` replaces zsh + tmux + mosh with a single piece: shell with history/completion/job-control, native multiplexing (no `tmux`), remote sessions (no `mosh`), all inside a Llimphi 4-slot chassis (TopBar, Main, BottomBar, DrawerTab + Quake drawer). 8-block roadmap (target 2026-05-25). `matilda` is the sibling tool for declarative multi-host configuration.
## Install
```sh
cargo run --release -p shuma-shell-llimphi
cargo run --release -p shuma-cli
cargo run --release -p shuma-daemon
```
## Compatibility
- **Linux / macOS / Windows** — shell + Llimphi UI.
- **Wawa** — runs inside the kernel.
- `shuma-protocol` enables local-client + remote-server without SSH.
Crates listed in [README.md](README.md) (shuma + matilda).
## Considerations
- **Replacement, not addition.** If you use shuma, you can uninstall zsh/tmux/mosh; behavior fully covered.
- **`intent → command`** is optional; without LLM the traditional shell runs unchanged.
- Remote sessions use **`shuma-protocol`** over TCP/TLS — no SSH daemon required.
+29
View File
@@ -0,0 +1,29 @@
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
# shuma
> Shell interactivo zsh/fish kasqaqlla, Llimphi chasis patanpi.
`shuma` zsh + tmux + mosh suyaspa, sapan p'aki hina: shell history/completion/job-control, natural multiplexing (manan `tmux`), karu sesiones (manan `mosh`), Llimphi 4-slot chasispi (TopBar, Main, BottomBar, DrawerTab + Quake drawer). 8-bloque roadmap (suyay 2026-05-25). `matilda` huk multi-host declarativo herramienta.
## Churay
```sh
cargo run --release -p shuma-shell-llimphi
cargo run --release -p shuma-cli
cargo run --release -p shuma-daemon
```
## Tinkuy
- **Linux / macOS / Windows** — shell + Llimphi UI.
- **Wawa** — kernel ukhupi.
- `shuma-protocol` lokal-cliente + karu-server, mana SSHwan.
Crateskuna [README.md](README.md)-pi.
## Yuyaykunaq
- **Suyay, mana aymachay.** Shuma usaspa, zsh/tmux/mosh wikch'ay atinki.
- **`intent → comando`** opcional; mana LLM tradicional shell hina.
- Karu sesiones **`shuma-protocol`** TCP/TLS patanpi — manan SSH daemon munana.
+367
View File
@@ -0,0 +1,367 @@
# shuma — reporte técnico para IA
> Estado: **2026-05-31** · rama `main` · compila limpio (`cargo build -p shuma-shell-llimphi -p shuma-daemon -p shuma-cli -p shuma-gateway`).
> Audiencia: sesión de Claude futura u otra IA que retome el shell+plugins. Idioma del proyecto: español.
## Estado (2026-05-31)
### Hecho
- **Chasis Llimphi completo** (`shuma-shell-llimphi`): slots TopBar/Main/BottomBar +
tabs, monitores (CPU/MEM) con splitter, i18n vía `rimay_localize`, theme/locale
vivos desde el bus `wawa-config`.
- **Bloques AF del roadmap cerrados (2026-05-28/29)**: REPL usable (streaming no
bloqueante, decoración clickeable del output, `LineState` con completion+ghost,
historial JSONL+fuzzy, PTY+vt100 con resize dinámico, paste con bracketed paste,
33/33 tests); daemon como ejecutor (local/daemon/`DaemonTcp` Noise_XK) + sidecar
al broker; launcher y commandbar reales (Cmd-P con nucleo_matcher); integración
wawa (watcher + theme/lang live); limpieza (SO_PEERCRED, parser de bindings, lienzo).
- **Lienzo de intenciones shell↔canvas** (2026-05-29): cada `start_run` aparece como
`%cN` en el `SessionGraph`; nodo verde/rojo según exit; canvas clickeable que
inserta `%cN`/`%pN` en el cursor del shell.
- **Adiós al Quake-drawer** (2026-05-29): el chasis es app standalone normal (tabs
siempre visibles); el overlay launcher vive en `pata` (antes en el retirado
`mirada-launcher-llimphi`).
- **vim como card themeable** (2026-05): PTY con skin app-aware, drag-to-select +
copia al clipboard, paste con click derecho/medio, iconitos por tipo en paths.
- **Menús** (lote 4): menú principal + menús contextuales en el chasis.
- **Stack matilda** (`baremetal/`): config declarativa multi-host (core/plan/discover/
config/apply/ghost/linker/app) + `shuma-module-matilda` (tab con SSH real).
- **Refactors regla #1**: split de `shuma-module-shell` (3028 LOC), `shuma-core`
(1517) y `shuma-shell-llimphi` main (1522) en módulos.
### Pendiente
- **E2 — hover trigger del drawer**: bloqueado por dispatching de pointer enter/leave
en `llimphi-ui`.
- **Mouse en el PTY**: vt100 ya parsea los eventos; falta cablear el mouse de Llimphi.
- **Tooltip "what would clicking this do?"** en decoraciones (espera al hover de llimphi-ui).
- **Cablear `shuma-line::decorate`** completo desde más consumidores (ya hace mucho,
poco consumido).
- **Daemon**: lockfile + check de PID vivo (hoy ignora bind si el socket existe).
- **Placeholders residuales**: aunque launcher/commandbar ya tienen impl real, varios
crates sandbox del listado siguen sin app que los consuma directamente.
---
## 1. Mapa del subárbol `02_ruway/shuma/`
```
shuma/
├── shuma-cli/ ← CLI admin del daemon (postcard sobre Unix socket)
├── shuma-daemon/ ← runtime: dueño de Workspaces, admin socket, reaper
├── shuma-gateway/ ← bridge HTTP/JSON ↔ postcard (1 endpoint: POST /rpc)
├── shuma-shell-llimphi/ ← CHASIS gráfico (Llimphi) — host de los módulos
├── baremetal/ ← stack de "matilda" (admin server declarativa)
│ ├── matilda-core, -plan, -discover, -apply, -ghost, -linker, -config, -app
└── sandbox/ ← crates de soporte del shell (sync, agnósticos de UI)
├── shuma-card ← Workspace/Pipeline/CommandRef → card_core::Card
├── shuma-core ← runtime in-memory (Mutex<HashMap>), reap, persist
├── shuma-protocol ← wire postcard u32-BE-prefix (daemon ↔ cli/gui)
├── shuma-discern ← discerners (magic-bytes, JSON, TOML, UTF8, Card)
├── shuma-exec ← ejecución sync: Direct / Shell / Pty; eventos mpsc
├── shuma-link ← Noise_XK + identity X25519 + FramedChannel
├── shuma-remote-exec ← cliente sync del ExecStream del daemon
├── shuma-line ← lex/parse/decorate/complete del input (sin frontend)
├── shuma-history ← JSONL append-only + fuzzy (nucleo_matcher)
├── shuma-session ← WorkSession (cwd, runs, grupos)
├── shuma-intent ← grafo de intenciones %cN/%pN
├── shuma-shell-render ← CanvasPlan (lienzo de contexto agnóstico)
├── shuma-sysmon ← /proc/stat + /proc/meminfo + historial
├── shuma-module ← contrato estructural de módulos (sin trait dyn)
├── shuma-module-shell ← MVP REPL (sh -c, sync, builtins)
├── shuma-module-matilda ← admin declarativa como tab del shell
├── shuma-module-launcher ← PLACEHOLDER
└── shuma-module-commandbar ← PLACEHOLDER
```
---
## 2. Arquitectura en una pantalla
```
┌────────────────────────────┐
shuma-cli ─postcard──┐ │ shuma-shell-llimphi │
shuma-gateway ─json──┤ │ (chasis Llimphi) │
▼ │ ┌──────────────────────┐ │
┌─────────────┴┐ │ Slots: │ │
│ shuma-daemon │ │ TopBar (launcher) │ │
│ (admin sock)│ │ Main (matilda…) │ │
│ + reaper │ │ Drawer [shell|…] │ │
│ + Workspace │ │ BottomBar (cmdbar) │ │
│ Manager │ └──────────────────────┘ │
└──────┬───────┘ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ shuma-module-shell │ │
│ │ ↓ (cuando se cablee)│ │
│ │ shuma-exec / -line │ │
│ │ -history / -session│ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ shuma-module-matilda│ │
│ │ → baremetal/matilda│ │
│ └─────────────────────┘ │
└────────────────────────────────────┘
(vía shuma-protocol — hoy NO cableado
desde el shell; el shell ejecuta local
con sh -c, no via daemon)
```
**Puntos clave**:
- El chasis es **static-dispatch**: enum `Kind { Launcher, CommandBar, Shell, Matilda }`. Agregar un módulo = variante + ramas en `update`/`view`. Sortea que `llimphi-ui` no tenga `View::map`.
- Cada módulo expone `pub fn make(host) -> ...`; el binario `shuma-shell-llimphi` enlaza estáticamente y mapea `ModuleMsg → ShellMsg` con un cierre (`lift`).
- El daemon, la CLI y el gateway son una **familia paralela** al chasis. El módulo `shuma-module-shell` ejecuta hoy directo con `sh -c` (no habla con el daemon).
---
## 3. Qué está hecho
### 3.1 Chasis gráfico (`shuma-shell-llimphi`, 1 588 LOC)
- **Layout completo**: TopBar, Main, BottomBar, Drawer-Quake (40 % altura por defecto), monitor stack con stat-cards + curvas (CPU, MEM + monitores aportados por módulos).
- **Slots configurables** vía `shumarc-modules.toml` (`src/config.rs`): cualquier `id` no compilado se ignora con warning — el shumarc no rompe el arranque.
- **Drawer**: toggle por F12, cerrar por Esc, click en command-bar abre. *Hover trigger pendiente* (`main.rs:40`: faltan enter/leave events en llimphi-ui).
- **Toolbar de shortcuts** alimentada por `ModuleContributions` (declarativo).
- **Resize del panel de monitores** con drag (splitter).
- **i18n**: `rimay_localize::init()` en `main` — todas las cadenas vía `t("shuma-…")`.
### 3.2 Daemon stack (`shuma-daemon` + `shuma-cli` + `shuma-gateway`)
- **Protocolo** (`shuma-protocol`, 589 LOC): postcard sobre Unix socket; `Request`/`Response` con Workspace CRUD, Run one-shot, Pipeline, ExecStream, Discern, Health, Caps.
- **Daemon** (1 279 LOC): `WorkspaceManager` (Mutex<HashMap>), reap cada 500 ms, drena pipelines en restart, persist a disco, sidecar pool opcional al broker `card_sidecar`.
- **CLI** (`shuma`, 740 LOC): subcomandos `ping`, `health`, `caps`, `workspace {create|list|stop}`, `run`, `commands`, `discern`, `pipeline …`.
- **Gateway HTTP** (168 LOC): `POST /rpc` con body JSON → postcard → daemon. Bind por env `SHIPOTE_GATEWAY_LISTEN`, default `127.0.0.1:7378`. Sin axum/hyper — parser ad-hoc.
- **Noise_XK** (`shuma-link`, ~860 LOC): handshake, `KnownPeers` (allowlist tipo `authorized_keys`), `Keypair` X25519 en `~/.config/shuma/keys/identity.x25519`, `FramedChannel` (length-prefix + chacha20-poly1305). Listo para reemplazar Unix socket por TCP autenticado.
- **Discern** (`shuma-discern`): pipeline configurable (MagicBytes → CardProbe → JsonProbe → TomlProbe → Utf8Probe).
### 3.3 Stack matilda (`baremetal/`)
- **`matilda-core`**: modelo declarativo (Host, Container, VHost, Inventory).
- **`matilda-plan`**: diff inventario actual vs deseado → `Vec<Action>` ordenado.
- **`matilda-discover`**: lee estado real (v1: por nombre — detecta creates y orphans, no cambios de config de un recurso existente).
- **`matilda-config`**: `Container → docker run`, `VHost → server { … }` de nginx. Funciones puras.
- **`matilda-apply`**: `Action → ApplyStep` (archivos + comandos), agnóstico de transporte.
- **`matilda-ghost`**: ejecutor local (`set -e`), reporta `ApplyReport`.
- **`matilda-linker`**: ejecutor SSH (sobre `brahman-ssh-multiplex`), mismo `ApplyReport`.
- **`matilda-app`** (CLI standalone): `matilda example | plan | script | apply | dry-run` local y remoto.
- **`shuma-module-matilda`** (1 120 LOC, **el módulo más completo**): tab del shell con inventario + plan + log + monitor de "pasos pendientes" + 3 shortcuts (Discover/Plan/Dry-run). Soporta `Source::Local` y `Source::Remote { host, user }` con SSH real. Recarga inventario desde el shumarc.
### 3.4 Línea + ejecución sync (sandbox)
Cinco crates listos pero **NO enchufados** al `shuma-module-shell` actual:
- **`shuma-exec`** (PTY incluido): `Exec::{Direct, Shell, Pty}`, eventos por mpsc (`Stdout`/`Stderr`/`Bytes`/`Truncated`/`Spilled`/`Done`), capture-limit + spill a disco, splice(2) zero-copy.
- **`shuma-line`**: tokenize + clasificación, `split_pipeline`, `complete` (con `flag_hints`), `ghost_suggestion`, `decorate_line` (paths clickeables, URLs, grep refs, SHA, `#NN`), `needs_continuation`, parser ANSI completo.
- **`shuma-history`**: JSONL append-only, fuzzy con nucleo_matcher, dedup configurable.
- **`shuma-session`**: WorkSession con cwd, `CommandRun` (estado + salida acotada), grupos guardados.
- **`shuma-shell-render`**: CanvasPlan (lienzo de contexto del grafo de intenciones, agnóstico de UI).
- **`shuma-remote-exec`**: cliente sync del subprotocolo `ExecStream` del daemon — API espejo de `shuma-exec::RunHandle`. Listo para reemplazar `sh -c` por *ejecución contra el daemon*.
### 3.5 Estado actual del REPL (`shuma-module-shell`, ~2000 LOC)
**Bloque A completo (2026-05-28).** El REPL ya es una pieza usable.
- **A1** ejecución no bloqueante: streaming via `shuma-exec`, drenado por `Msg::ShellTick` a 100 ms. Cola si hay run vivo. Cancel = SIGKILL al grupo (`process_group(0)` + `killpg`).
- **A2** decoración del output: `shuma_line::decorate_line` por línea; paths/URLs/grep-refs/issue/box-draw → `theme.accent`; git SHAs → `theme.fg_muted`.
- **A3** input inteligente: `LineState` con tokens coloreados, cursor visible, ghost suggestion del historial. Tab completion (binarios en `$PATH` + paths bajo cwd + flag hints + prefijo común con N candidatos). ArrowRight al final acepta ghost. Ctrl+Arrow palabra, Home/End.
- **A4** historial durable: JSONL en `$XDG_DATA_HOME/shuma/history.jsonl`. Up/Down navegan; Ctrl-R abre overlay `fuzzy_search`.
- **A5** PTY + vt100: allowlist + prefijo `:tui``Exec::Pty`. `vt100::Parser` alimentado por bytes; render del panel = grid de celdas con `paint_with`. Teclas → xterm bytes.
- **A6** resize dinámico del PTY: `shuma_exec::RunHandle::resize(rows, cols)` expuesto vía `MasterPty` en `Arc<Mutex<>>`; `tui_panel` painter publica el `PaintRect` en `state.last_tui_rect`; cada `drain_run` mira si cambió y manda `MasterPty::resize` + reescala el screen del `vt100::Parser`. vim/htop reciben SIGWINCH y reflowean.
- **A7** click handlers en decoraciones: `Msg::OpenDecoration(DecorationKind)`. Path-dir → cd (más recálculo del `ShellSource`); Path-executable → llena el input con el path; Path-archivo / URL → `xdg-open` detached; GrepRef → `$EDITOR +line file`; GitSha → llena el input con `git show <sha>`. Render del output ahora es `FlexDirection::Row` con un nodo por span (los actionables llevan `on_click`).
- **A8** paste + bracketed paste: Ctrl-V y Shift+Insert leen el clipboard (vía `arboard`). Sin TUI → `LineState::insert`. Con TUI → `RunHandle::write_input`; si el child habilitó bracketed paste (DECSET 2004, leído de `screen.bracketed_paste()`), la secuencia se envuelve en `\x1b[200~…\x1b[201~` para que vim/emacs distingan tipeo de pegado.
- Builtins: `cd`, `pwd`, `clear`, `exit`. Tope 500 líneas en el buffer.
- Tests: **33/33 verde** (timing del ejecutor, navegación de historial, tab/ghost/clicks/paste, build_spec routing, key→PTY bytes, palette ansi, partition_line, decoration handlers, PTY resize end-to-end con `stty size`).
---
## 4. Wawa — qué hay y qué falta
`wawa-config` (en `shared/wawa-config`) es el **bus de configuración del SO wawa**: archivo JSON canónico (system: `/etc/wawa/config.json`, user: `$XDG_CONFIG_HOME/wawa/config.json`), watcher `notify` sobre ambos paths, atomic save (`tmp + rename`). Sin daemon pub-sub: las apps leen el archivo y se suscriben a cambios. Esto sobrevive a la transición Linux → arje (cuando wawa sea su propio SO, `system_path()` cambia, el resto no).
Forma actual de la config:
```json
{
"theme_variant": "dark", "accent": "default",
"lang": "es-PE", "timefmt_24h": true,
"modules": { "mirada": true, "shuma": true, "chasqui": true,
"akasha": true, "minga": true, "agora": true }
}
```
**Estado de la integración shuma ↔ wawa (2026-05-28): activa.**
- `shuma-shell-llimphi::init` carga `WawaConfig::load()` + `theme_from_wawa(&wawa, &Theme::dark())` + `rimay_localize::set_locale(&wawa.lang)` antes de armar las instancias, así el primer render ya sale con el theme y locale correctos.
- Un `wawa_config::ConfigWatcher` corre en background; cada cambio dispara `Msg::WawaConfigChanged(Box<WawaConfig>)` vía `Handle::dispatch`.
- El handler re-arma `m.theme` con el nuevo variant/accent (fallback al theme actual si el variant es desconocido) y reinvoca `set_locale` — sin reiniciar el chasis, sin re-cargar las instancias. Los próximos `view()` ya pintan con la paleta nueva; los strings que viajan por `t(...)` también se rehidratan al cambiar.
**Contrato dividido (D3):**
- **`shumarc-modules.toml`** (TOML, project-local): topología de la UI del shell — qué módulo se monta en qué slot (TopBar/Main/BottomBar/Drawer), labels custom, Source (Local/Daemon/DaemonTcp/Remote). Esto es estructura de la app y vive con la app.
- **`$XDG_CONFIG_HOME/wawa/config.json`** (JSON, perfil del usuario): preferencias visuales (`theme_variant`, `accent`), locale (`lang`), formato del reloj (`timefmt_24h`), bitmask de qué apps están on (`modules.{shuma, mirada, pluma, …}`). Esto es preferencia del usuario y es compartida por **todas** las apps Llimphi de gioser (pluma, dominium, cosmos, nada, nakui, shuma…).
El toggle `modules.shuma = false` en el JSON wawa no apaga el binario corriendo (el chasis no se suicida); el efecto es que los launchers no listan a shuma como app activa. La supervisión del binario en sí es decisión del SO (wawa-init en el futuro arje, o systemd/manual hoy).
---
## 5. Plan propuesto (priorizado)
### Bloque A — desbloquear el REPL ✅ **completo (2026-05-28)**
Ver §3.5 para el detalle del estado actual. Resumen:
- A1 ✅ ejecución no bloqueante + cola + cancel SIGKILL al grupo
- A2 ✅ decoración del output (paths/URLs/SHAs/grep-refs/issue/box-draw)
- A3 ✅ LineState + tokens coloreados + Tab completion + ghost
- A4 ✅ historial durable JSONL + Up/Down + Ctrl-R fuzzy overlay
- A5 ✅ PTY + emulador vt100 (vía `vt100` crate) + render de grid
- A6 ✅ resize dinámico del PTY (`RunHandle::resize` + tracking del PaintRect del panel)
- A7 ✅ click handlers sobre decoraciones (Path/Url/GrepRef/GitSha)
- A8 ✅ paste con bracketed paste (`arboard` + DECSET 2004)
Pendientes opcionales (no bloquean nada):
- Mouse en el PTY (vt100 ya parsea los eventos; falta cablear el mouse de Llimphi).
- Tooltip "what would clicking this do?" en decoraciones (espera al hover en llimphi-ui).
### Bloque B — integrar el daemon como ejecutor ✅ **completo (2026-05-28)**
B1 ✅ **Runner enum local/daemon** en `shuma-module-shell`. `BackendHandle` envuelve `shuma_exec::RunHandle` y `shuma_remote_exec::RemoteRunHandle` con la misma API (`try_events`, `is_finished`, `kill`, `write_input`, `resize` — write/resize son no-op en remoto). `Source` extendido con variantes `Daemon { socket: Option<PathBuf>, label }` y `DaemonTcp { addr, server_pub_hex, label }`; `start_run` rutea según la variante. PTY siempre cae a local con notice (daemon no soporta PTY remoto).
B2 ✅ **Source remoto via Noise XK**. `Source::DaemonTcp` consume `shuma_remote_exec::run_tcp`. Identidad X25519 del shell persiste vía `shuma_link::Keypair::load_or_generate(Keypair::default_path())` — primer arranque genera, después se reusa. `server_pub_hex` parseado con `PublicKey::from_hex`. Errores (no hay daemon, pubkey errónea) salen como notice en el output sin tumbar el shell.
B3 ✅ **Sidecar broker en daemon**. `WorkspaceCreate` ahora llama a `pool.spawn(build_workspace_card(label, id))` cuando hay pool — cada workspace se publica al broker como `Card { kind: Ente, lifecycle: Daemon, flow: ["commands"] }` (paralelo a la `shuma.daemon` card que ya existía). `announce_edges_to_broker` para edges de pipeline ya estaba.
### Bloque C — módulos placeholder ✅ **completo (2026-05-28)**
C1 ✅ **launcher real con manifests**. `shuma-module-launcher` ahora lee `$XDG_CONFIG_HOME/shuma/apps/*.toml` (orden alfabético) en `State::from_apps_dir()`. Cada manifest es `{label, exec?, action_id?}`; si tiene `exec`, click → spawn detached (`process_group(0)`); si no, emite `Msg::EntryClicked(action_id)` al chasis. Si el dir no existe o no hay manifests válidos, cae a `State::demo()` para que el chasis siga exploratorio. Chasis llama a `from_apps_dir()` en lugar de `demo()`.
C2 ✅ **commandbar real Cmd-P**. `shuma-module-commandbar` ahora trae catálogo de `CommandEntry { label, category, kind: FocusTab|Exec|Action }` provisionable vía `State::set_catalog`. Tipear filtra con `nucleo_matcher::Pattern::score`; Up/Down navegan; Enter activa (`activation_for(&state, &ev)` retorna `CommandKind`); Escape limpia; click en row → `ActivateAt(idx)`. Modo `Launcher` usa el catálogo, modo `Shell` ejecuta la línea tal cual (`CommandKind::Exec(text)`). Dropdown se muestra encima de la barra con hasta 8 matches.
### Bloque D — wawa integration
D1. Suscribir watcher `wawa-config` en `shuma-shell-llimphi::main`.
D2. Reaccionar a cambios de `theme_variant`/`accent`/`lang` sin reiniciar (`Theme::for_variant` + `rimay_localize::set_lang`).
D3. Documentar el contrato: el shumarc topología (qué módulos en qué slots) sigue siendo TOML aparte; el JSON wawa es para preferencias visuales y toggle de apps.
### Bloque E — limpieza pendiente
E1 ✅ `audit_request(peer: &str, req)` — Unix socket pasa `uid:1000` desde `SO_PEERCRED`; TCP autenticado pasa `pubkey:<16 hex>` (primeros 16 chars de la X25519 del peer).
E2 ⏳ Hover trigger del drawer Quake — bloqueado por dispatching de `on_pointer_enter/leave` en `llimphi-ui` (WIP del usuario en curso; los campos y métodos públicos existen pero el runtime no los emite todavía).
E3 ✅ Parser real de teclas en shumarc — `parse_binding` acepta `Ctrl+Shift+Space`, `Super+grave`, `Alt+F1`, etc. Modifiers: `Ctrl/Alt/Shift/Super` (con alias `Meta/Cmd/Win`). Named keys: F1..F24, Escape, Enter, Space, Tab, Backspace, Delete, Home, End, PageUp/Down, Arrows, Insert, grave. Tests cubren combos.
E4 ✅ `shuma-module-canvas` consume el `SessionGraph` directo (layout in-tree para no arrastrar `pineal-render` al chasis).
### Bloque F — features grandes (post-A/B)
F1. Lienzo de contexto: panel adicional que renderice `shuma-intent::SessionGraph` con `shuma-shell-render::CanvasPlan`. El grafo `%cN`/`%pN` ya existe en `shuma-intent`; falta la UI y el parser de intents en la commandbar.
F2. Job control en el módulo shell: `:jobs`, `:term`, `:stop`, `:cont`, sufijo `&` (`shuma-exec` ya soporta multi-run + kill).
F3. Editor multi-línea: `shuma-line::continuation::needs_continuation` ya está; falta cablear al input.
---
## 6. Decisiones de diseño que conviene preservar
1. **Static dispatch sobre trait objects**: `Kind` enum + `ModuleState` enum. Coste: una rama por módulo en `update`/`view`. Beneficio: cada módulo declara su `Msg` propio sin pelearse con `Box<dyn Any>` y sin downcast.
2. **Sync por dentro, async sólo en bordes**: `shuma-exec`/`shuma-remote-exec` son sync (threads + mpsc); el daemon es tokio. El shell es sync — drena eventos en cada `Tick`. No tirar este patrón "porque tokio es lo moderno": Llimphi es sync.
3. **El módulo no depende de `llimphi-ui` desde `shuma-module`**: sólo desde su crate concreto. Esto deja `shuma-module` (el contrato) testeable sin display.
4. **El daemon ignora errores de bind si existe el socket** (`main.rs:30`): asume restart limpio. *Pendiente*: lockfile + check de PID vivo.
5. **El gateway no usa axum**: parser HTTP ad-hoc en ~120 LOC. No agregar axum sólo para "ser idiomático" — un POST único no lo justifica.
6. **Notación de slots** del shumarc: `[topbar]`, `[main]`, `[bottombar]`, `[[drawer.tabs]]`. Mantener — está documentada en `config.rs:1-44`.
7. **`shuma-protocol::DEFAULT_SOCK_NAME = "shuma.sock"`** en `$XDG_RUNTIME_DIR`. No mover.
---
## 7. Trampas conocidas
- **El binario `shuma-shell` GPUI (3.7k LOC) ya no existe** — se borró en `b92b643`. Cualquier referencia a "shuma-shell" en docs viejas es a esa versión. Las features grandes (completion, decoración, historial) viven en sandbox/* sueltas, no en un shell ensamblado.
- **`russh v0.54.5`** dispara warning de future-incompat — no bloquea, llega vía `matilda-linker`.
- **`gpui extinto en gioser** (memoria del proyecto): nada nuevo sobre GPUI. Todo gráfico es Llimphi.
- **El módulo matilda en remoto SÍ ejecuta SSH real** (vía `matilda-linker`/`brahman-ssh-multiplex`); las pruebas reales necesitan un servidor con sshd alcanzable.
- **`shuma-line::decorate` ya hace mucho** (paths clickeables, URLs, SHAs, grep refs) pero ningún consumidor lo usa hoy — fácil ganancia al cablearlo a `shuma-module-shell`.
---
## 8. Ranking de prioridad
| # | Tarea | Ganancia | Costo |
|---|-------|----------|-------|
| ✅ | A1..A8 — bloque REPL extendido | shell completo | hecho 2026-05-28 |
| ✅ | B1..B3 — daemon ejecutor + broker | shell remoto + observable | hecho 2026-05-28 |
| ✅ | C1..C2 — launcher + commandbar reales | palette Cmd-P + apps | hecho 2026-05-28 |
| ✅ | D1..D3 — wawa watcher + theme/lang live | preferencias unificadas | hecho 2026-05-28 |
| ✅ | E1 E3 E4 — limpieza | SO_PEERCRED, parser bindings, lienzo | hecho 2026-05-28 |
| ✅ | F2 F3 F1 — features grandes | jobs, multi-línea, lienzo | hecho 2026-05-28 |
| ✅ | shell↔canvas live | runs del shell aparecen como `%cN` en el lienzo | hecho 2026-05-29 |
| ⏳ | E2 — hover trigger drawer | requiere WIP llimphi-ui (pointer events) | bloqueado |
**Integración shell↔canvas (2026-05-29).** `shuma-module-shell` mantiene
su propio `SessionGraph` (campo `intent_graph` en `State`) y registra
cada `start_run` como `%cN`. `drain_run` acumula bytes de
stdout/stderr/raw y al cerrar el run llama `complete(id, ok, bytes)`
nodo verde si `exit 0`, rojo en cualquier otro caso (incluidos errores
de spawn del backend remoto). Builtins (`cd`/`pwd`/`clear`/`exit` y los
`:jobs/:term/:stop/:cont`) no entran al grafo. El chasis añadió
`Kind::Canvas` con tab nuevo "Lienzo" en el drawer por defecto; cada
`SHELL_TICK` (~100 ms) `sync_canvas_from_primary_shell` empuja el grafo
del primer shell encontrado a todas las instancias canvas con
`Msg::SyncGraph(graph)`. El lienzo refleja al instante el flujo de la
sesión (3 tests nuevos en `shell` + 1 en `canvas`).
**Adiós al Quake-drawer (2026-05-29, tercer bloque).** El chasis dejó
de ser una imitación del launcher overlay y volvió a ser app standalone
normal: tabs siempre visibles, sin F12, sin Esc-cierra-drawer, sin
overlay absoluto sobre el escritorio. Eso vive en `pata` (antes en el
retirado `mirada-launcher-llimphi`). Cambios concretos:
- `Model`: `drawer_tabs``tabs`, `active_drawer_tab``active_tab`,
fuera `drawer_open` y `drawer_trigger`.
- `Msg`: fuera `ToggleDrawer`, `CloseDrawer`, `SelectDrawerTab`
queda `SelectTab(usize)`.
- `Slot::DrawerTab(usize)``Slot::Tab(usize)`.
- `on_key` ya no atrapa F12 ni Esc; `forward_key_to_focused_shell`
prioriza `Slot::Main` y cae al `tabs[active_tab]`.
- `render_main_area` se simplificó: si el shumarc declara `[main]`,
ocupa todo el área (sin tabs ni monitores). Si no, tabs + splitter
con monitores a la derecha. No hay más `Position::Absolute` ni
capas overlay.
- shumarc TOML: `[[drawer.tabs]]``[[tabs]]`, fuera `[drawer.trigger]`.
- `parse_binding` + `matches_key` + `tests_bindings` (todo para
reconocer el shortcut de toggle drawer) borrados.
- i18n: `shuma-empty-no-drawer-tabs`/`-compat` renombrados a
`shuma-empty-no-tabs`/`-compat`; `shuma-empty-no-main` y el hint que
mencionaba "F12 abre el drawer" eliminados.
**Canvas clickeable (2026-05-29, segundo bloque).** Las cajas del
lienzo responden al click vía `on_click_at` + `hit_test_box`: el
primer click enfoca el `%cN` (borde 3.5 px en lugar de 2.0), el
segundo desenfoca, y un click en vacío también desenfoca. Cuando hay
un nodo enfocado aparece una tira inferior con la intención completa,
status y bytes; al lado, dos botones "Insertar %cN" / "Insertar %pN"
emiten `Msg::InsertRef(text)`. El chasis intercepta esta variante
(`apply_module_msg` antes de routear al canvas), busca el primer
`Shell` con `first_shell_slot`, abre+enfoca el drawer si está en una
tab, y le manda `Msg::InsertAtCursor(text)` al shell. El shell inserta
en la posición actual del cursor del `LineState`, cierra el overlay
Ctrl-R si estaba abierto y deja el cursor justo después del texto.
`SyncGraph` ahora limpia `focused` si el nodo desapareció del snapshot
nuevo (evita detalle stale). 6 tests nuevos en canvas + 1 en shell.
Pendientes opcionales restantes:
- Mouse en el PTY (vt100 ya parsea; falta cablear el mouse de Llimphi).
- Tooltip "what would clicking this do?" en decoraciones (espera al hover de llimphi-ui).
---
## 9. Comandos útiles para retomar
```bash
# Compilar todo el subárbol shuma
cargo build -p shuma-shell-llimphi -p shuma-daemon -p shuma-cli -p shuma-gateway
# Probar el chasis (necesita servidor gráfico Llimphi)
cargo run -p shuma-shell-llimphi
# Daemon + CLI rápida
cargo run -p shuma-daemon &
cargo run -p shuma-cli -- health
# Gateway HTTP
SHIPOTE_GATEWAY_LISTEN=127.0.0.1:7378 cargo run -p shuma-gateway
# Estado de los crates sandbox (sin app que los consuma)
wc -l 02_ruway/shuma/sandbox/*/src/*.rs
```
---
*Generado por Claude (Opus 4.7) — `2026-05-27`. Si el plan cambia, actualizá la tabla de la §8 antes de tocar la §3.*
@@ -0,0 +1,25 @@
[package]
name = "matilda"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "matilda — CLI de administración de servidores: carga un inventario, muestra el plan, emite el script y lo aplica (local, remoto por SSH, o en seco)."
[[bin]]
name = "matilda"
path = "src/main.rs"
[dependencies]
matilda-core = { path = "../matilda-core" }
matilda-config = { path = "../matilda-config" }
matilda-plan = { path = "../matilda-plan" }
matilda-apply = { path = "../matilda-apply" }
matilda-ghost = { path = "../matilda-ghost" }
matilda-linker = { path = "../matilda-linker" }
matilda-discover = { path = "../matilda-discover" }
clap = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
@@ -0,0 +1,16 @@
# matilda-app
> CLI/UI de [matilda](../../README.md).
Comandos: `matilda discover`, `matilda plan`, `matilda apply`, `matilda ghost`, `matilda link`. UI Llimphi opcional para review del plan antes de aplicar.
## Uso
```sh
cargo run --release -p matilda-app -- apply
```
## Deps
- Todos los `matilda-*`
- `clap`, opcional [`llimphi-ui`](../../../llimphi/)
@@ -0,0 +1,16 @@
# matilda-app
> CLI/UI of [matilda](../../README.md).
Commands: `matilda discover`, `matilda plan`, `matilda apply`, `matilda ghost`, `matilda link`. Optional Llimphi UI for plan review before applying.
## Usage
```sh
cargo run --release -p matilda-app -- apply
```
## Deps
- All `matilda-*`
- `clap`, optional [`llimphi-ui`](../../../llimphi/)
@@ -0,0 +1,238 @@
//! `matilda` — CLI de administración de servidores.
//!
//! Carga un inventario declarativo (JSON), lo reconcilia contra el
//! estado actual y aplica los cambios — localmente, en seco, o en un
//! servidor remoto por SSH:
//!
//! ```text
//! matilda example imprime un inventario de ejemplo
//! matilda plan inv.json muestra el plan de reconciliación
//! matilda script inv.json emite el script de aplicación
//! matilda apply inv.json aplica localmente
//! matilda apply inv.json --dry-run simula
//! matilda apply inv.json --host deploy@srv aplica por SSH
//! ```
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use matilda_apply::{plan_to_steps, steps_to_script, ApplyStep};
use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost};
use matilda_ghost::ApplyReport;
use matilda_linker::{Linker, SshAuth, SshConfig};
use matilda_plan::{plan, Op};
#[derive(Parser)]
#[command(name = "matilda", about = "Administración declarativa de servidores")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Imprime un inventario de ejemplo para editar.
Example,
/// Muestra el plan de reconciliación del inventario.
Plan {
inventory: PathBuf,
/// Estado actual del servidor (por defecto: vacío).
#[arg(long)]
current: Option<PathBuf>,
/// Descubre el estado actual de esta máquina (docker + nginx).
#[arg(long)]
discover: bool,
},
/// Emite el script de shell que aplicaría el plan.
Script {
inventory: PathBuf,
#[arg(long)]
current: Option<PathBuf>,
#[arg(long)]
discover: bool,
},
/// Aplica el plan: local, en seco, o remoto por SSH.
Apply {
inventory: PathBuf,
#[arg(long)]
current: Option<PathBuf>,
/// Descubre el estado actual de esta máquina antes de reconciliar.
#[arg(long)]
discover: bool,
/// Simula sin tocar nada.
#[arg(long)]
dry_run: bool,
/// Aplica en un host remoto, `usuario@host`.
#[arg(long)]
host: Option<String>,
/// Contraseña SSH (si no se da, se usa la clave por defecto).
#[arg(long)]
password: Option<String>,
},
}
/// Carga un inventario JSON desde un archivo.
fn load(path: &PathBuf) -> Result<Inventory, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("no se pudo leer {}: {e}", path.display()))?;
serde_json::from_str(&text).map_err(|e| format!("JSON inválido en {}: {e}", path.display()))
}
/// Resuelve el inventario "actual" contra el que reconciliar:
/// `--discover` observa esta máquina; `--current` lee un archivo; si no,
/// se parte de un inventario vacío (todo es creación).
fn current_inventory(
discover: bool,
current: &Option<PathBuf>,
desired: &Inventory,
) -> Result<Inventory, String> {
if discover {
// Descubrimiento detallado: `docker inspect` detecta el drift.
Ok(matilda_discover::discover_inventory(desired))
} else {
match current {
Some(p) => load(p),
None => Ok(Inventory::new()),
}
}
}
/// Construye un inventario de ejemplo.
fn example_inventory() -> Inventory {
let mut inv = Inventory::new();
inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
inv.add_container(
Container::new("web", "nginx:1.27")
.with_port(8080, 80)
.with_volume("/srv/site", "/usr/share/nginx/html")
.with_restart(RestartPolicy::Always),
);
inv.add_container(
Container::new("api", "ghcr.io/ejemplo/api:1.0")
.with_port(9000, 9000)
.with_env("DATABASE_URL", "postgres://db/app")
.with_restart(RestartPolicy::UnlessStopped),
);
inv.add_vhost(
VHost::to_container("sitio.com", "web", 80)
.with_alias("www.sitio.com")
.with_tls(),
);
inv
}
/// Imprime un `ApplyReport` legible.
fn print_report(report: &ApplyReport) {
for r in &report.results {
println!("\n{} {}", if r.ok { "" } else { "" }, r.describe);
for l in &r.log {
println!(" {l}");
}
}
println!(
"\n{} de {} pasos aplicados.",
report.applied(),
report.results.len()
);
if !report.all_ok() {
println!("✘ se detuvo en el primer error.");
}
}
/// Aplica los pasos en un host remoto por SSH.
async fn apply_remote(
target: &str,
password: Option<String>,
steps: &[ApplyStep],
) -> Result<ApplyReport, String> {
let (user, host) = target
.split_once('@')
.ok_or_else(|| format!("host inválido (esperaba usuario@host): {target}"))?;
let auth = match password {
Some(pw) => SshAuth::Password(pw),
None => {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
SshAuth::Key {
path: PathBuf::from(format!("{home}/.ssh/id_ed25519")),
passphrase: None,
}
}
};
let config = SshConfig::new(host, user, auth);
let linker = Linker::connect(&config)
.await
.map_err(|e| format!("conexión SSH: {e}"))?;
Ok(linker.apply(steps).await)
}
fn run() -> Result<(), String> {
match Cli::parse().cmd {
Cmd::Example => {
let json = serde_json::to_string_pretty(&example_inventory())
.map_err(|e| e.to_string())?;
println!("{json}");
}
Cmd::Plan { inventory, current, discover } => {
let desired = load(&inventory)?;
let p = plan(&current_inventory(discover, &current, &desired)?, &desired);
if p.is_empty() {
println!("Sin cambios: el servidor ya está al día.");
} else {
for (i, action) in p.actions.iter().enumerate() {
println!("{:>2}. {}", i + 1, action.describe());
}
println!(
"\n{} acciones — {} crear, {} actualizar, {} eliminar.",
p.len(),
p.count(Op::Create),
p.count(Op::Update),
p.count(Op::Remove),
);
}
}
Cmd::Script { inventory, current, discover } => {
let desired = load(&inventory)?;
let p = plan(&current_inventory(discover, &current, &desired)?, &desired);
print!("{}", steps_to_script(&plan_to_steps(&p, &desired)));
}
Cmd::Apply { inventory, current, discover, dry_run, host, password } => {
let desired = load(&inventory)?;
let p = plan(&current_inventory(discover, &current, &desired)?, &desired);
let steps = plan_to_steps(&p, &desired);
if steps.is_empty() {
println!("Sin cambios: nada que aplicar.");
return Ok(());
}
let report = if dry_run {
println!("— simulación (no se toca nada) —");
matilda_ghost::dry_run(&steps)
} else if let Some(target) = host {
println!("— aplicando en {target} por SSH —");
let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
rt.block_on(apply_remote(&target, password, &steps))?
} else {
println!("— aplicando localmente —");
matilda_ghost::apply(&steps)
};
print_report(&report);
if !report.all_ok() {
return Err("la aplicación falló".into());
}
}
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
@@ -0,0 +1,14 @@
[package]
name = "matilda-apply"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "matilda — puente plan→ejecución: traduce un Plan de reconciliación a pasos concretos (archivos a escribir + comandos a correr) listos para aplicar en el servidor."
[dependencies]
matilda-core = { path = "../matilda-core" }
matilda-plan = { path = "../matilda-plan" }
matilda-config = { path = "../matilda-config" }
serde = { workspace = true }
@@ -0,0 +1,10 @@
# matilda-apply
> Ejecutor del plan de [matilda](../../README.md).
Aplica `Vec<Action>` de [`matilda-plan`](../matilda-plan/README.md). Cada action loguea + revertible. Para checks duros pide confirmación interactiva.
## Deps
- [`matilda-plan`](../matilda-plan/README.md)
- `tokio`
@@ -0,0 +1,10 @@
# matilda-apply
> Plan executor of [matilda](../../README.md).
Applies `Vec<Action>` from [`matilda-plan`](../matilda-plan/README.md). Each action is logged + revertible. For hard checks, asks for interactive confirmation.
## Deps
- [`matilda-plan`](../matilda-plan/README.md)
- `tokio`
@@ -0,0 +1,208 @@
//! `matilda-apply` — el puente entre el plan y la ejecución real.
//!
//! `matilda-plan` dice *qué* cambiar (una lista ordenada de `Action`s).
//! Este crate dice *cómo*: traduce cada acción a un [`ApplyStep`]
//! concreto — los archivos a escribir en el servidor y los comandos a
//! correr, en orden.
//!
//! Sigue siendo **agnóstico de transporte**: no abre conexiones ni
//! ejecuta nada. Aplicar los pasos —localmente, por SSH o vía el agente
//! `matilda-ghost`— es trabajo de la capa de I/O. Aquí todo es una
//! función pura y testeable.
#![forbid(unsafe_code)]
use matilda_config::{docker_run_command, nginx_server_block};
use matilda_core::Inventory;
use matilda_plan::{Op, Plan, Resource};
use serde::{Deserialize, Serialize};
/// Directorio donde matilda deja los `server` de nginx.
const NGINX_SITES: &str = "/etc/nginx/sites-enabled";
/// Un archivo a escribir en el servidor.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileWrite {
pub path: String,
pub content: String,
}
/// Un paso de aplicación: la traducción concreta de una acción del plan.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApplyStep {
/// Descripción legible de la acción de origen.
pub describe: String,
/// Archivos a escribir en el servidor (antes de los comandos).
pub files: Vec<FileWrite>,
/// Comandos de shell a ejecutar, en orden.
pub commands: Vec<String>,
}
/// Ruta del archivo `server` de un dominio.
fn vhost_path(domain: &str) -> String {
format!("{NGINX_SITES}/{domain}.conf")
}
/// Traduce un plan a pasos concretos de aplicación.
///
/// Necesita el inventario **deseado** para conocer los detalles de cada
/// recurso (imagen del contenedor, upstream del vhost). Las acciones
/// sobre *hosts* no producen pasos: un host es a qué servidor conectarse,
/// no algo que se "aplique" en él.
pub fn plan_to_steps(plan: &Plan, desired: &Inventory) -> Vec<ApplyStep> {
let mut steps = Vec::new();
for action in &plan.actions {
let describe = action.describe();
let step = match (action.op, action.resource) {
// --- Contenedores ---
(Op::Create, Resource::Container) => desired
.container(&action.name)
.map(|c| ApplyStep {
describe,
files: Vec::new(),
commands: vec![docker_run_command(c)],
}),
(Op::Update, Resource::Container) => desired.container(&action.name).map(|c| {
ApplyStep {
describe,
files: Vec::new(),
// Recrear: quitar el viejo, lanzar el nuevo.
commands: vec![
format!("docker rm -f {}", action.name),
docker_run_command(c),
],
}
}),
(Op::Remove, Resource::Container) => Some(ApplyStep {
describe,
files: Vec::new(),
commands: vec![format!("docker rm -f {}", action.name)],
}),
// --- VHosts ---
(Op::Create | Op::Update, Resource::VHost) => {
desired.vhost(&action.name).map(|v| ApplyStep {
describe,
files: vec![FileWrite {
path: vhost_path(&action.name),
content: nginx_server_block(v),
}],
commands: vec!["nginx -t && nginx -s reload".to_string()],
})
}
(Op::Remove, Resource::VHost) => Some(ApplyStep {
describe,
files: Vec::new(),
commands: vec![
format!("rm -f {}", vhost_path(&action.name)),
"nginx -t && nginx -s reload".to_string(),
],
}),
// --- Hosts: no se "aplican" (son destino de conexión) ---
(_, Resource::Host) => None,
};
if let Some(step) = step {
steps.push(step);
}
}
steps
}
/// Vuelca los pasos a un script de shell único — útil para revisarlo, o
/// para ejecutarlo de un tirón en el servidor. Los archivos se emiten
/// como heredocs.
pub fn steps_to_script(steps: &[ApplyStep]) -> String {
let mut out = String::from("#!/usr/bin/env bash\nset -euo pipefail\n");
for step in steps {
out.push_str(&format!("\n# {}\n", step.describe));
for f in &step.files {
out.push_str(&format!("cat > {} <<'MATILDA_EOF'\n", f.path));
out.push_str(&f.content);
if !f.content.ends_with('\n') {
out.push('\n');
}
out.push_str("MATILDA_EOF\n");
}
for cmd in &step.commands {
out.push_str(cmd);
out.push('\n');
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use matilda_core::{Container, VHost};
fn desired() -> Inventory {
let mut inv = Inventory::new();
inv.add_container(Container::new("web", "nginx:1.27").with_port(8080, 80));
inv.add_vhost(VHost::to_container("site.com", "web", 8080));
inv
}
#[test]
fn fresh_inventory_produces_create_steps() {
let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired());
assert_eq!(steps.len(), 2); // un contenedor + un vhost
// El contenedor se crea con `docker run`.
assert!(steps[0].commands[0].starts_with("docker run -d --name web"));
// El vhost escribe su archivo y recarga nginx.
assert_eq!(steps[1].files.len(), 1);
assert!(steps[1].files[0].path.ends_with("site.com.conf"));
assert!(steps[1].commands[0].contains("nginx -s reload"));
}
#[test]
fn update_recreates_the_container() {
let mut current = Inventory::new();
current.add_container(Container::new("web", "nginx:1.25"));
let steps = plan_to_steps(&matilda_plan::plan(&current, &desired()), &desired());
let cont = steps.iter().find(|s| s.describe.contains("contenedor")).unwrap();
assert_eq!(cont.commands[0], "docker rm -f web");
assert!(cont.commands[1].starts_with("docker run"));
}
#[test]
fn removal_steps_clean_up() {
let mut current = Inventory::new();
current.add_container(Container::new("viejo", "img"));
current.add_vhost(VHost::to_address("viejo.com", "1.2.3.4:80"));
let steps = plan_to_steps(&matilda_plan::plan(&current, &Inventory::new()), &Inventory::new());
let cmds: Vec<&str> = steps
.iter()
.flat_map(|s| s.commands.iter())
.map(|s| s.as_str())
.collect();
assert!(cmds.iter().any(|c| c.contains("docker rm -f viejo")));
assert!(cmds.iter().any(|c| c.contains("rm -f") && c.contains("viejo.com")));
}
#[test]
fn host_actions_produce_no_steps() {
let mut desired = Inventory::new();
desired.add_host(matilda_core::Host::new("edge", "10.0.0.1"));
let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired), &desired);
assert!(steps.is_empty());
}
#[test]
fn empty_plan_yields_no_steps() {
let inv = desired();
let steps = plan_to_steps(&matilda_plan::plan(&inv, &inv.clone()), &inv);
assert!(steps.is_empty());
}
#[test]
fn script_emits_heredocs_and_commands() {
let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired());
let script = steps_to_script(&steps);
assert!(script.starts_with("#!/usr/bin/env bash"));
assert!(script.contains("docker run -d --name web"));
assert!(script.contains("cat > /etc/nginx/sites-enabled/site.com.conf <<'MATILDA_EOF'"));
assert!(script.contains("MATILDA_EOF"));
}
}
@@ -0,0 +1,11 @@
[package]
name = "matilda-config"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "matilda — renderizado de configuración: del modelo declarativo a comandos docker run, servicios docker-compose y bloques server de nginx."
[dependencies]
matilda-core = { path = "../matilda-core" }
@@ -0,0 +1,9 @@
# matilda-config
> Loader de archivos de [matilda](../../README.md).
Lee `matilda.toml` + includes, resuelve variables, valida contra el schema de [`matilda-core`](../matilda-core/README.md).
## Deps
- [`matilda-core`](../matilda-core/README.md), `toml`
@@ -0,0 +1,9 @@
# matilda-config
> File loader of [matilda](../../README.md).
Reads `matilda.toml` + includes, resolves variables, validates against [`matilda-core`](../matilda-core/README.md)'s schema.
## Deps
- [`matilda-core`](../matilda-core/README.md), `toml`
@@ -0,0 +1,105 @@
//! Renderizado de un [`Container`] a Docker — `docker run` y compose.
use matilda_core::Container;
/// Comando `docker run` de un contenedor, en una sola línea. El orden de
/// los flags es fijo (determinista): `-d --name --restart -p -e -v img`.
pub fn docker_run_command(c: &Container) -> String {
let mut parts: Vec<String> = vec![
"docker".into(),
"run".into(),
"-d".into(),
"--name".into(),
c.name.clone(),
"--restart".into(),
c.restart.docker_flag().into(),
];
for p in &c.ports {
parts.push("-p".into());
parts.push(format!("{}:{}", p.host, p.container));
}
for (k, v) in &c.env {
parts.push("-e".into());
parts.push(format!("{k}={v}"));
}
for (host, container) in &c.volumes {
parts.push("-v".into());
parts.push(format!("{host}:{container}"));
}
parts.push(c.image.clone());
parts.join(" ")
}
/// Bloque de servicio para un `docker-compose.yml`. Viene indentado para
/// colocarse tal cual bajo la clave `services:`.
pub fn compose_service(c: &Container) -> String {
let mut out = String::new();
out.push_str(&format!(" {}:\n", c.name));
out.push_str(&format!(" image: {}\n", c.image));
out.push_str(&format!(" restart: {}\n", c.restart.docker_flag()));
if !c.ports.is_empty() {
out.push_str(" ports:\n");
for p in &c.ports {
out.push_str(&format!(" - \"{}:{}\"\n", p.host, p.container));
}
}
if !c.env.is_empty() {
out.push_str(" environment:\n");
for (k, v) in &c.env {
out.push_str(&format!(" - {k}={v}\n"));
}
}
if !c.volumes.is_empty() {
out.push_str(" volumes:\n");
for (host, container) in &c.volumes {
out.push_str(&format!(" - {host}:{container}\n"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use matilda_core::RestartPolicy;
fn sample() -> Container {
Container::new("web", "nginx:1.27")
.with_port(8080, 80)
.with_env("TZ", "America/Caracas")
.with_volume("/srv/web", "/usr/share/nginx/html")
.with_restart(RestartPolicy::Always)
}
#[test]
fn run_command_has_all_flags() {
let cmd = docker_run_command(&sample());
assert!(cmd.starts_with("docker run -d --name web --restart always"));
assert!(cmd.contains("-p 8080:80"));
assert!(cmd.contains("-e TZ=America/Caracas"));
assert!(cmd.contains("-v /srv/web:/usr/share/nginx/html"));
assert!(cmd.ends_with("nginx:1.27"));
}
#[test]
fn run_command_is_deterministic() {
assert_eq!(docker_run_command(&sample()), docker_run_command(&sample()));
}
#[test]
fn compose_service_indents_under_services() {
let yaml = compose_service(&sample());
assert!(yaml.contains(" web:\n"));
assert!(yaml.contains(" image: nginx:1.27\n"));
assert!(yaml.contains(" restart: always\n"));
assert!(yaml.contains(" - \"8080:80\"\n"));
}
#[test]
fn minimal_container_omits_empty_sections() {
let yaml = compose_service(&Container::new("bare", "alpine"));
assert!(!yaml.contains("ports:"));
assert!(!yaml.contains("environment:"));
assert!(!yaml.contains("volumes:"));
}
}
@@ -0,0 +1,63 @@
//! `matilda-config` — del modelo declarativo a archivos de configuración.
//!
//! Funciones puras: toman un tipo de `matilda-core` y devuelven el texto
//! de configuración listo para escribir en el servidor. No tocan disco
//! ni Docker — sólo construyen strings, así que cada salida es testeable
//! y determinista.
//!
//! - [`docker`] — `Container` → `docker run` / servicio docker-compose.
//! - [`nginx`] — `VHost` → bloque `server` de nginx.
#![forbid(unsafe_code)]
pub mod docker;
pub mod nginx;
pub use docker::{compose_service, docker_run_command};
pub use nginx::nginx_server_block;
use matilda_core::Inventory;
/// Renderiza el `docker-compose.yml` completo de un inventario.
pub fn compose_file(inv: &Inventory) -> String {
let mut out = String::from("services:\n");
for c in inv.containers() {
out.push_str(&compose_service(c));
}
out
}
/// Renderiza el archivo de sites de nginx — un bloque `server` por
/// vhost, separados por una línea en blanco.
pub fn nginx_sites(inv: &Inventory) -> String {
inv.vhosts()
.map(nginx_server_block)
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use matilda_core::{Container, VHost};
#[test]
fn compose_file_lists_every_container() {
let mut inv = Inventory::new();
inv.add_container(Container::new("web", "nginx"));
inv.add_container(Container::new("db", "postgres:16"));
let yaml = compose_file(&inv);
assert!(yaml.starts_with("services:\n"));
assert!(yaml.contains(" web:\n") && yaml.contains(" db:\n"));
}
#[test]
fn nginx_sites_renders_every_vhost() {
let mut inv = Inventory::new();
inv.add_vhost(VHost::to_container("a.com", "web", 80));
inv.add_vhost(VHost::to_container("b.com", "web", 80));
let conf = nginx_sites(&inv);
assert!(conf.contains("server_name a.com;"));
assert!(conf.contains("server_name b.com;"));
}
}
@@ -0,0 +1,94 @@
//! Renderizado de un [`VHost`] a un bloque `server` de nginx.
use matilda_core::{Upstream, VHost};
/// URL de `proxy_pass` para un upstream. Un contenedor se referencia por
/// su nombre, que la red de Docker resuelve a su IP interna.
fn proxy_target(upstream: &Upstream) -> String {
match upstream {
Upstream::Address(addr) => format!("http://{addr}"),
Upstream::Container { name, port } => format!("http://{name}:{port}"),
}
}
/// Renderiza el `server` de nginx de un vhost. Con TLS emite dos
/// bloques: el `:443 ssl` y un `:80` que redirige a HTTPS.
pub fn nginx_server_block(v: &VHost) -> String {
let names: Vec<&str> = std::iter::once(v.domain.as_str())
.chain(v.aliases.iter().map(|s| s.as_str()))
.collect();
let server_name = names.join(" ");
let target = proxy_target(&v.upstream);
let mut out = String::new();
if v.tls {
// Redirección :80 → :443.
out.push_str("server {\n");
out.push_str(" listen 80;\n");
out.push_str(&format!(" server_name {server_name};\n"));
out.push_str(" return 301 https://$host$request_uri;\n");
out.push_str("}\n\n");
out.push_str("server {\n");
out.push_str(" listen 443 ssl;\n");
out.push_str(&format!(" server_name {server_name};\n"));
out.push_str(&format!(
" ssl_certificate /etc/letsencrypt/live/{}/fullchain.pem;\n",
v.domain
));
out.push_str(&format!(
" ssl_certificate_key /etc/letsencrypt/live/{}/privkey.pem;\n",
v.domain
));
} else {
out.push_str("server {\n");
out.push_str(" listen 80;\n");
out.push_str(&format!(" server_name {server_name};\n"));
}
out.push_str(" location / {\n");
out.push_str(&format!(" proxy_pass {target};\n"));
out.push_str(" proxy_set_header Host $host;\n");
out.push_str(" proxy_set_header X-Real-IP $remote_addr;\n");
out.push_str(" }\n");
out.push_str("}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_vhost_listens_on_80() {
let block = nginx_server_block(&VHost::to_container("app.com", "web", 8080));
assert!(block.contains("listen 80;"));
assert!(!block.contains("listen 443"));
assert!(block.contains("server_name app.com;"));
assert!(block.contains("proxy_pass http://web:8080;"));
}
#[test]
fn tls_vhost_adds_443_and_redirect() {
let block = nginx_server_block(&VHost::to_address("secure.com", "10.0.0.5:80").with_tls());
assert!(block.contains("listen 443 ssl;"));
assert!(block.contains("return 301 https://$host$request_uri;"));
assert!(block.contains("/etc/letsencrypt/live/secure.com/fullchain.pem"));
assert!(block.contains("proxy_pass http://10.0.0.5:80;"));
}
#[test]
fn aliases_join_the_server_name() {
let v = VHost::to_address("main.com", "1.2.3.4:80")
.with_alias("www.main.com")
.with_alias("alt.com");
let block = nginx_server_block(&v);
assert!(block.contains("server_name main.com www.main.com alt.com;"));
}
#[test]
fn render_is_deterministic() {
let v = VHost::to_container("x.com", "c", 80).with_tls();
assert_eq!(nginx_server_block(&v), nginx_server_block(&v));
}
}
@@ -0,0 +1,11 @@
[package]
name = "matilda-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "matilda — modelo de dominio de administración de servidores: Host, Container, VHost y el Inventory que los agrupa. Agnóstico de transporte y de Docker."
[dependencies]
serde = { workspace = true }
@@ -0,0 +1,9 @@
# matilda-core
> Modelo de config declarativa de [shuma/matilda](../../README.md).
`HostConfig { packages, files, services, dotfiles, ... }` serializable a TOML. La verdad de "cómo debería estar el host" se escribe acá.
## Deps
- `serde`, `toml`
@@ -0,0 +1,9 @@
# matilda-core
> Declarative config model of [shuma/matilda](../../README.md).
`HostConfig { packages, files, services, dotfiles, ... }` serializable to TOML. The truth of "how the host should be" is written here.
## Deps
- `serde`, `toml`
@@ -0,0 +1,132 @@
//! `Container` — la especificación declarativa de un contenedor Docker.
//!
//! Es sólo el *deseo*: qué imagen, qué puertos, qué entorno. Ejecutar
//! Docker es trabajo de capas superiores; aquí el contenedor es un dato
//! comparable (`PartialEq`) para que el plan detecte cambios.
use serde::{Deserialize, Serialize};
/// Política de reinicio del contenedor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RestartPolicy {
/// Nunca reiniciar.
#[default]
No,
/// Reiniciar sólo si salió con error.
OnFailure,
/// Reiniciar siempre.
Always,
/// Reiniciar salvo que se haya detenido a mano.
UnlessStopped,
}
impl RestartPolicy {
/// Valor tal como lo espera el flag `--restart` de Docker.
pub fn docker_flag(self) -> &'static str {
match self {
RestartPolicy::No => "no",
RestartPolicy::OnFailure => "on-failure",
RestartPolicy::Always => "always",
RestartPolicy::UnlessStopped => "unless-stopped",
}
}
}
/// Un mapeo de puerto `host → contenedor`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PortMap {
pub host: u16,
pub container: u16,
}
impl PortMap {
pub fn new(host: u16, container: u16) -> Self {
Self { host, container }
}
}
/// La especificación declarativa de un contenedor. Clave única: `name`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Container {
pub name: String,
/// Imagen con etiqueta — `"nginx:1.27"`, `"postgres:16"`.
pub image: String,
pub ports: Vec<PortMap>,
/// Variables de entorno, ordenadas por clave para comparación estable.
pub env: Vec<(String, String)>,
/// Volúmenes `ruta_host → ruta_contenedor`.
pub volumes: Vec<(String, String)>,
pub restart: RestartPolicy,
}
impl Container {
/// Contenedor mínimo: nombre + imagen.
pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
Self {
name: name.into(),
image: image.into(),
ports: Vec::new(),
env: Vec::new(),
volumes: Vec::new(),
restart: RestartPolicy::default(),
}
}
/// Publica un puerto (encadenable).
pub fn with_port(mut self, host: u16, container: u16) -> Self {
self.ports.push(PortMap::new(host, container));
self
}
/// Define una variable de entorno (encadenable). El vector se
/// mantiene ordenado por clave para que dos contenedores con el
/// mismo entorno comparen iguales sin importar el orden de llamada.
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let key = key.into();
self.env.retain(|(k, _)| k != &key);
self.env.push((key, value.into()));
self.env.sort_by(|a, b| a.0.cmp(&b.0));
self
}
/// Monta un volumen (encadenable).
pub fn with_volume(
mut self,
host_path: impl Into<String>,
container_path: impl Into<String>,
) -> Self {
self.volumes.push((host_path.into(), container_path.into()));
self
}
/// Fija la política de reinicio (encadenable).
pub fn with_restart(mut self, restart: RestartPolicy) -> Self {
self.restart = restart;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_order_does_not_affect_equality() {
let a = Container::new("c", "img").with_env("B", "2").with_env("A", "1");
let b = Container::new("c", "img").with_env("A", "1").with_env("B", "2");
assert_eq!(a, b);
}
#[test]
fn with_env_overwrites_same_key() {
let c = Container::new("c", "img").with_env("K", "old").with_env("K", "new");
assert_eq!(c.env, vec![("K".to_string(), "new".to_string())]);
}
#[test]
fn restart_flags_match_docker() {
assert_eq!(RestartPolicy::UnlessStopped.docker_flag(), "unless-stopped");
assert_eq!(RestartPolicy::default(), RestartPolicy::No);
}
}
@@ -0,0 +1,46 @@
//! `Host` — un servidor administrado.
use serde::{Deserialize, Serialize};
/// Un servidor bajo administración. La clave única es `name`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Host {
/// Nombre lógico — clave de inventario, no necesariamente el hostname.
pub name: String,
/// IP o nombre DNS por el que se alcanza.
pub address: String,
/// Etiquetas libres — `"prod"`, `"db"`, `"edge"`.
pub tags: Vec<String>,
}
impl Host {
pub fn new(name: impl Into<String>, address: impl Into<String>) -> Self {
Self { name: name.into(), address: address.into(), tags: Vec::new() }
}
/// Añade una etiqueta (encadenable). No duplica.
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
let tag = tag.into();
if !self.tags.contains(&tag) {
self.tags.push(tag);
}
self
}
/// `true` si el host lleva la etiqueta `tag`.
pub fn has_tag(&self, tag: &str) -> bool {
self.tags.iter().any(|t| t == tag)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_tag_dedups() {
let h = Host::new("edge-1", "10.0.0.1").with_tag("prod").with_tag("prod");
assert_eq!(h.tags.len(), 1);
assert!(h.has_tag("prod"));
}
}
@@ -0,0 +1,131 @@
//! `Inventory` — el estado declarado de la infraestructura.
//!
//! Reúne hosts, contenedores y vhosts. Cada colección es un `BTreeMap`
//! por nombre: toda iteración es determinista y el `diff` de
//! `matilda-plan` produce siempre el mismo orden de acciones.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::container::Container;
use crate::host::Host;
use crate::vhost::VHost;
/// El inventario completo — la fuente de verdad declarativa.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Inventory {
hosts: BTreeMap<String, Host>,
containers: BTreeMap<String, Container>,
vhosts: BTreeMap<String, VHost>,
}
impl Inventory {
pub fn new() -> Self {
Self::default()
}
// --- Hosts ---
pub fn add_host(&mut self, host: Host) {
self.hosts.insert(host.name.clone(), host);
}
pub fn host(&self, name: &str) -> Option<&Host> {
self.hosts.get(name)
}
pub fn hosts(&self) -> impl Iterator<Item = &Host> {
self.hosts.values()
}
// --- Contenedores ---
pub fn add_container(&mut self, container: Container) {
self.containers.insert(container.name.clone(), container);
}
pub fn container(&self, name: &str) -> Option<&Container> {
self.containers.get(name)
}
pub fn containers(&self) -> impl Iterator<Item = &Container> {
self.containers.values()
}
// --- VHosts ---
pub fn add_vhost(&mut self, vhost: VHost) {
self.vhosts.insert(vhost.domain.clone(), vhost);
}
pub fn vhost(&self, domain: &str) -> Option<&VHost> {
self.vhosts.get(domain)
}
pub fn vhosts(&self) -> impl Iterator<Item = &VHost> {
self.vhosts.values()
}
// --- Consultas transversales ---
/// `true` si el inventario no tiene nada declarado.
pub fn is_empty(&self) -> bool {
self.hosts.is_empty() && self.containers.is_empty() && self.vhosts.is_empty()
}
/// VHosts cuyo upstream apunta a un contenedor inexistente — la
/// inconsistencia más común de un inventario.
pub fn broken_vhosts(&self) -> Vec<&VHost> {
self.vhosts
.values()
.filter(|v| {
v.depends_on_container()
.is_some_and(|c| !self.containers.contains_key(c))
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_and_query_each_kind() {
let mut inv = Inventory::new();
inv.add_host(Host::new("edge", "10.0.0.1"));
inv.add_container(Container::new("web", "nginx:1.27"));
inv.add_vhost(VHost::to_container("site.com", "web", 80));
assert!(inv.host("edge").is_some());
assert!(inv.container("web").is_some());
assert!(inv.vhost("site.com").is_some());
assert!(!inv.is_empty());
}
#[test]
fn broken_vhosts_point_to_missing_containers() {
let mut inv = Inventory::new();
inv.add_vhost(VHost::to_container("site.com", "fantasma", 80));
inv.add_vhost(VHost::to_address("static.com", "1.2.3.4:80"));
let broken: Vec<_> = inv.broken_vhosts().iter().map(|v| v.domain.clone()).collect();
assert_eq!(broken, vec!["site.com"]);
}
#[test]
fn vhost_with_present_container_is_not_broken() {
let mut inv = Inventory::new();
inv.add_container(Container::new("web", "nginx:1.27"));
inv.add_vhost(VHost::to_container("site.com", "web", 80));
assert!(inv.broken_vhosts().is_empty());
}
#[test]
fn iteration_is_ordered_by_name() {
let mut inv = Inventory::new();
inv.add_container(Container::new("zeta", "img"));
inv.add_container(Container::new("alfa", "img"));
let names: Vec<_> = inv.containers().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["alfa", "zeta"]);
}
}
@@ -0,0 +1,26 @@
//! `matilda-core` — el modelo de dominio de administración de servidores.
//!
//! matilda administra servidores, sus contenedores Docker y los hosts
//! virtuales de proxy inverso. Este crate es la parte declarativa y
//! pura: describe *qué* debe existir, sin tocar Docker, SSH ni archivos.
//!
//! - [`host`] — [`Host`], un servidor administrado.
//! - [`container`] — [`Container`], la spec declarativa de un contenedor.
//! - [`vhost`] — [`VHost`], un host virtual de proxy inverso.
//! - [`inventory`] — [`Inventory`], el estado declarado completo.
//!
//! El renderizado de configuración vive en `matilda-config`; la
//! reconciliación deseado-vs-actual, en `matilda-plan`; el transporte
//! (SSH «Linker», agente «Ghost»), en capas superiores.
#![forbid(unsafe_code)]
pub mod container;
pub mod host;
pub mod inventory;
pub mod vhost;
pub use container::{Container, PortMap, RestartPolicy};
pub use host::Host;
pub use inventory::Inventory;
pub use vhost::{Upstream, VHost};
@@ -0,0 +1,88 @@
//! `VHost` — un host virtual de proxy inverso.
use serde::{Deserialize, Serialize};
/// El destino al que un `VHost` reenvía el tráfico.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Upstream {
/// Una dirección `host:puerto` literal.
Address(String),
/// Un contenedor del inventario, por nombre y puerto interno.
Container { name: String, port: u16 },
}
/// Un host virtual: un dominio que se reenvía a un upstream. Clave
/// única: `domain`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VHost {
pub domain: String,
pub upstream: Upstream,
/// Si se sirve sobre HTTPS.
pub tls: bool,
/// Dominios alternativos que resuelven al mismo upstream.
pub aliases: Vec<String>,
}
impl VHost {
/// VHost que apunta a una dirección literal.
pub fn to_address(domain: impl Into<String>, address: impl Into<String>) -> Self {
Self {
domain: domain.into(),
upstream: Upstream::Address(address.into()),
tls: false,
aliases: Vec::new(),
}
}
/// VHost que apunta a un contenedor del inventario.
pub fn to_container(
domain: impl Into<String>,
container: impl Into<String>,
port: u16,
) -> Self {
Self {
domain: domain.into(),
upstream: Upstream::Container { name: container.into(), port },
tls: false,
aliases: Vec::new(),
}
}
/// Activa TLS (encadenable).
pub fn with_tls(mut self) -> Self {
self.tls = true;
self
}
/// Añade un alias de dominio (encadenable).
pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
self.aliases.push(alias.into());
self
}
/// Nombre del contenedor del que depende, si el upstream es uno.
pub fn depends_on_container(&self) -> Option<&str> {
match &self.upstream {
Upstream::Container { name, .. } => Some(name),
Upstream::Address(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn container_upstream_reports_its_dependency() {
let v = VHost::to_container("app.example.com", "web", 8080).with_tls();
assert_eq!(v.depends_on_container(), Some("web"));
assert!(v.tls);
}
#[test]
fn address_upstream_has_no_container_dependency() {
let v = VHost::to_address("static.example.com", "10.0.0.9:80");
assert_eq!(v.depends_on_container(), None);
}
}
@@ -0,0 +1,16 @@
[package]
name = "matilda-discover"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "matilda — descubrimiento del estado actual de un servidor: qué contenedores y vhosts existen, para reconciliar contra el inventario deseado."
[dependencies]
matilda-core = { path = "../matilda-core" }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
matilda-plan = { path = "../matilda-plan" }
@@ -0,0 +1,10 @@
# matilda-discover
> Descubrimiento de estado actual de [matilda](../../README.md).
Lee el sistema (paquetes instalados, archivos en `/etc`, servicios systemd) y produce un `HostConfig` "actual". Comparable con el deseado para calcular el diff.
## Deps
- [`matilda-core`](../matilda-core/README.md)
- `dbus` (systemd), `walkdir`
@@ -0,0 +1,10 @@
# matilda-discover
> Current-state discovery of [matilda](../../README.md).
Reads the system (installed packages, files in `/etc`, systemd services) and produces the "actual" `HostConfig`. Comparable with the desired state to compute the diff.
## Deps
- [`matilda-core`](../matilda-core/README.md)
- `dbus` (systemd), `walkdir`
@@ -0,0 +1,349 @@
//! `matilda-discover` — qué hay realmente en el servidor.
//!
//! Para reconciliar de verdad hace falta saber el estado *actual*: qué
//! contenedores y vhosts existen. Este crate lo observa y lo reconstruye
//! como un [`Inventory`] que `matilda-plan` puede diferenciar contra el
//! deseado.
//!
//! Alcance v1: descubre por **nombre**. Detecta correctamente lo que hay
//! que **crear** y lo que hay que **eliminar** (huérfanos). No detecta
//! cambios de configuración de un recurso existente — eso necesita
//! inspección detallada (`docker inspect`), aún no implementada; un
//! recurso presente y deseado se asume sin cambios.
//!
//! El parseo es puro y testeable; sólo [`discover_local`] toca el sistema.
#![forbid(unsafe_code)]
use matilda_core::{Container, Inventory, VHost};
use serde::{Deserialize, Serialize};
/// El estado observado de un servidor — los nombres de lo que existe.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerState {
/// Nombres de los contenedores presentes.
pub containers: Vec<String>,
/// Dominios de los vhosts presentes.
pub vhosts: Vec<String>,
}
/// Parsea la salida de `docker ps -a --format '{{.Names}}'` — un nombre
/// por línea.
pub fn parse_docker_names(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
/// Parsea un listado de `/etc/nginx/sites-enabled` — un archivo por
/// línea; el sufijo `.conf` se quita para quedarse con el dominio.
pub fn parse_nginx_sites(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| l.strip_suffix(".conf").unwrap_or(l).to_string())
.collect()
}
/// Reconstruye el inventario "actual" a partir de los nombres observados.
///
/// Un recurso presente que también está en `desired` se copia de ahí —
/// así el `plan` no marca cambios espurios (la detección real de drift
/// necesita inspección detallada). Un recurso presente que **no** está
/// en `desired` entra como un marcador, y el `plan` lo verá como un
/// `Remove`.
pub fn observed_inventory(state: &ServerState, desired: &Inventory) -> Inventory {
let mut inv = Inventory::new();
for name in &state.containers {
match desired.container(name) {
Some(c) => inv.add_container(c.clone()),
None => inv.add_container(Container::new(name, "(desconocido)")),
}
}
for domain in &state.vhosts {
match desired.vhost(domain) {
Some(v) => inv.add_vhost(v.clone()),
None => inv.add_vhost(VHost::to_address(domain, "(desconocido)")),
}
}
inv
}
/// Ejecuta un comando local y devuelve su stdout, o `None` si falla.
fn run_local(program: &str, args: &[&str]) -> Option<String> {
let out = std::process::Command::new(program).args(args).output().ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
// --- Detección de drift por `docker inspect` ----------------------------
/// Subconjunto de la salida de `docker inspect` que importa para el drift.
#[derive(Debug, Deserialize)]
struct DockerInspect {
#[serde(rename = "Config")]
config: DockerConfig,
#[serde(rename = "HostConfig")]
host_config: DockerHostConfig,
}
#[derive(Debug, Deserialize)]
struct DockerConfig {
#[serde(rename = "Image")]
image: String,
#[serde(default, rename = "Env")]
env: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
struct DockerHostConfig {
#[serde(default, rename = "Binds")]
binds: Option<Vec<String>>,
#[serde(default, rename = "PortBindings")]
port_bindings: std::collections::HashMap<String, Option<Vec<PortBinding>>>,
#[serde(default, rename = "RestartPolicy")]
restart_policy: DockerRestart,
}
#[derive(Debug, Default, Deserialize)]
struct DockerRestart {
#[serde(default, rename = "Name")]
name: String,
}
#[derive(Debug, Deserialize)]
struct PortBinding {
#[serde(rename = "HostPort")]
host_port: String,
}
/// `true` si el contenedor que está corriendo **se desvió** de lo que
/// declara `desired` — distinta imagen, puerto, env o volumen.
///
/// La comparación es por *satisfacción*: lo que el spec declara debe
/// estar; lo extra que traiga la imagen (su `PATH`, etc.) se ignora.
/// Si el JSON no se puede leer, se asume que no hay drift (no se marca
/// un cambio espurio).
pub fn container_drift(desired: &Container, inspect_json: &str) -> bool {
let parsed: Vec<DockerInspect> = match serde_json::from_str(inspect_json) {
Ok(v) => v,
Err(_) => return false,
};
let Some(d) = parsed.first() else {
return false;
};
// Imagen.
if d.config.image != desired.image {
return true;
}
// Política de reinicio (docker reporta "" cuando no hay → "no").
let actual = if d.host_config.restart_policy.name.is_empty() {
"no"
} else {
d.host_config.restart_policy.name.as_str()
};
if actual != desired.restart.docker_flag() {
return true;
}
// Cada puerto declarado debe estar publicado al host correcto.
for p in &desired.ports {
let key = format!("{}/tcp", p.container);
let published = d
.host_config
.port_bindings
.get(&key)
.and_then(|b| b.as_ref())
.map(|bs| bs.iter().any(|b| b.host_port == p.host.to_string()))
.unwrap_or(false);
if !published {
return true;
}
}
// Cada variable de entorno declarada debe estar presente.
for (k, v) in &desired.env {
let want = format!("{k}={v}");
if !d.config.env.iter().any(|e| e == &want) {
return true;
}
}
// Cada volumen declarado debe estar montado.
for (h, c) in &desired.volumes {
let want = format!("{h}:{c}");
if !d.host_config.binds.iter().flatten().any(|b| b.starts_with(&want)) {
return true;
}
}
false
}
/// Descubre el inventario actual **con detección de drift**: corre
/// `docker inspect` en cada contenedor y, si se desvió del spec deseado,
/// lo marca para que el `plan` emita un `Update`. Los contenedores al
/// día se copian del deseado (sin cambio); los huérfanos quedan marcados
/// para `Remove`. Los vhosts se descubren por nombre.
pub fn discover_inventory(desired: &Inventory) -> Inventory {
let mut inv = Inventory::new();
let names = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
.map(|t| parse_docker_names(&t))
.unwrap_or_default();
for name in names {
match desired.container(&name) {
Some(d) => {
let drifted = run_local("docker", &["inspect", &name])
.map(|json| container_drift(d, &json))
.unwrap_or(false);
if drifted {
// Marcador distinto del deseado → el plan verá `Update`.
inv.add_container(Container::new(&name, "(desviado)"));
} else {
inv.add_container(d.clone());
}
}
None => inv.add_container(Container::new(&name, "(huérfano)")),
}
}
for domain in run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
.map(|t| parse_nginx_sites(&t))
.unwrap_or_default()
{
match desired.vhost(&domain) {
Some(v) => inv.add_vhost(v.clone()),
None => inv.add_vhost(VHost::to_address(&domain, "(huérfano)")),
}
}
inv
}
/// Observa el estado de *esta* máquina: `docker ps` + los sitios de
/// nginx. Si docker no está o el directorio no existe, esa parte queda
/// vacía (no es un error — quizá el servidor aún no tiene nada).
pub fn discover_local() -> ServerState {
let containers = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
.map(|t| parse_docker_names(&t))
.unwrap_or_default();
let vhosts = run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
.map(|t| parse_nginx_sites(&t))
.unwrap_or_default();
ServerState { containers, vhosts }
}
#[cfg(test)]
mod tests {
use super::*;
use matilda_plan::{plan, Op};
#[test]
fn parses_docker_names() {
let names = parse_docker_names("web\napi\n\n db \n");
assert_eq!(names, vec!["web", "api", "db"]);
}
#[test]
fn parses_nginx_sites_stripping_conf() {
let sites = parse_nginx_sites("sitio.com.conf\napi.sitio.com.conf\n");
assert_eq!(sites, vec!["sitio.com", "api.sitio.com"]);
}
#[test]
fn observed_present_and_desired_diffs_clean() {
// Un contenedor presente que también se desea → sin cambios.
let mut desired = Inventory::new();
desired.add_container(Container::new("web", "nginx:1.27"));
let state = ServerState { containers: vec!["web".into()], vhosts: vec![] };
let current = observed_inventory(&state, &desired);
let p = plan(&current, &desired);
assert!(p.is_empty(), "presente y deseado → sin acciones");
}
#[test]
fn observed_orphan_becomes_a_removal() {
// Un contenedor presente que NO se desea → se elimina.
let desired = Inventory::new();
let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
let current = observed_inventory(&state, &desired);
let p = plan(&current, &desired);
assert_eq!(p.count(Op::Remove), 1);
assert_eq!(p.actions[0].name, "viejo");
}
#[test]
fn missing_desired_resource_becomes_a_creation() {
let mut desired = Inventory::new();
desired.add_container(Container::new("nuevo", "img:1"));
// El servidor no tiene nada.
let current = observed_inventory(&ServerState::default(), &desired);
let p = plan(&current, &desired);
assert_eq!(p.count(Op::Create), 1);
}
#[test]
fn create_and_remove_together() {
let mut desired = Inventory::new();
desired.add_container(Container::new("nuevo", "img:1"));
let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
let p = plan(&observed_inventory(&state, &desired), &desired);
assert_eq!(p.count(Op::Create), 1);
assert_eq!(p.count(Op::Remove), 1);
}
/// `docker inspect` de un `web` con nginx:1.27, 8080→80, un volumen,
/// la env TZ y reinicio `always`.
const INSPECT_WEB: &str = r#"[{
"Config": {
"Image": "nginx:1.27",
"Env": ["PATH=/usr/local/sbin", "TZ=UTC"]
},
"HostConfig": {
"Binds": ["/srv/web:/usr/share/nginx/html"],
"PortBindings": {"80/tcp": [{"HostPort": "8080"}]},
"RestartPolicy": {"Name": "always"}
}
}]"#;
fn web_spec() -> matilda_core::Container {
Container::new("web", "nginx:1.27")
.with_port(8080, 80)
.with_volume("/srv/web", "/usr/share/nginx/html")
.with_env("TZ", "UTC")
.with_restart(matilda_core::RestartPolicy::Always)
}
#[test]
fn no_drift_when_running_matches_the_spec() {
assert!(!container_drift(&web_spec(), INSPECT_WEB));
}
#[test]
fn drift_when_image_changed() {
let mut spec = web_spec();
spec.image = "nginx:1.25".into();
assert!(container_drift(&spec, INSPECT_WEB));
}
#[test]
fn drift_when_a_declared_port_is_missing() {
let spec = web_spec().with_port(9000, 9000);
assert!(container_drift(&spec, INSPECT_WEB));
}
#[test]
fn drift_when_a_declared_env_is_missing() {
let spec = web_spec().with_env("DEBUG", "1");
assert!(container_drift(&spec, INSPECT_WEB));
}
#[test]
fn drift_when_restart_policy_differs() {
let spec = web_spec().with_restart(matilda_core::RestartPolicy::No);
assert!(container_drift(&spec, INSPECT_WEB));
}
#[test]
fn unreadable_json_is_not_treated_as_drift() {
assert!(!container_drift(&web_spec(), "no es json"));
}
}
@@ -0,0 +1,12 @@
[package]
name = "matilda-ghost"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "matilda — el agente que ejecuta los ApplySteps en la máquina destino: escribe los archivos, corre los comandos y reporta el resultado paso a paso."
[dependencies]
matilda-apply = { path = "../matilda-apply" }
serde = { workspace = true }
@@ -0,0 +1,9 @@
# matilda-ghost
> Modo dry-run de [matilda](../../README.md).
Wrapper de [`matilda-apply`](../matilda-apply/README.md) que NO ejecuta — sólo imprime. Útil antes de un apply real.
## Deps
- [`matilda-apply`](../matilda-apply/README.md)
@@ -0,0 +1,9 @@
# matilda-ghost
> Dry-run mode of [matilda](../../README.md).
Wrapper of [`matilda-apply`](../matilda-apply/README.md) that does NOT execute — only prints what would. Useful before a real apply.
## Deps
- [`matilda-apply`](../matilda-apply/README.md)
@@ -0,0 +1,222 @@
//! `matilda-ghost` — el agente que aplica los pasos en la máquina destino.
//!
//! El «Ghost» es quien realmente ejecuta: recibe los [`ApplyStep`]s que
//! tradujo `matilda-apply` y, en orden, escribe los archivos y corre los
//! comandos en *esta* máquina (la del servidor). Reporta paso a paso en
//! un [`ApplyReport`].
//!
//! Semántica `set -e`: si un paso falla, se detiene — no se aplican los
//! siguientes. [`dry_run`] muestra lo que haría sin tocar nada.
//!
//! La aplicación *remota* (por SSH) la hace `matilda-linker`, que produce
//! el mismo [`ApplyReport`] reusando estos tipos.
#![forbid(unsafe_code)]
use matilda_apply::ApplyStep;
use serde::{Deserialize, Serialize};
/// Resultado de un paso de aplicación.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepResult {
/// Descripción de la acción aplicada.
pub describe: String,
/// `true` si el paso completó sin errores.
pub ok: bool,
/// Bitácora legible: archivos escritos, comandos y su salida.
pub log: Vec<String>,
}
/// El reporte de aplicar un plan: un resultado por paso ejecutado.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ApplyReport {
pub results: Vec<StepResult>,
}
impl ApplyReport {
/// `true` si todos los pasos ejecutados salieron bien.
pub fn all_ok(&self) -> bool {
self.results.iter().all(|r| r.ok)
}
/// Cantidad de pasos que salieron bien.
pub fn applied(&self) -> usize {
self.results.iter().filter(|r| r.ok).count()
}
/// El primer paso que falló, si lo hubo.
pub fn failed(&self) -> Option<&StepResult> {
self.results.iter().find(|r| !r.ok)
}
}
/// Corre un comando de shell, juntando su salida (stdout + stderr).
/// Los comandos de matilda llevan `&&`, redirecciones… → van por `sh -c`.
fn run_command(cmd: &str) -> std::io::Result<(i32, Vec<String>)> {
let out = std::process::Command::new("sh").arg("-c").arg(cmd).output()?;
let mut lines = Vec::new();
for chunk in [&out.stdout, &out.stderr] {
for l in String::from_utf8_lossy(chunk).lines() {
lines.push(l.to_string());
}
}
Ok((out.status.code().unwrap_or(-1), lines))
}
/// Aplica un paso en esta máquina: escribe sus archivos y corre sus
/// comandos. Devuelve el resultado; se detiene en el primer error.
fn apply_step(step: &ApplyStep) -> StepResult {
let mut log = Vec::new();
let mut ok = true;
for f in &step.files {
match std::fs::write(&f.path, &f.content) {
Ok(()) => log.push(format!("✔ escrito {}", f.path)),
Err(e) => {
log.push(format!("✘ no se pudo escribir {}: {e}", f.path));
ok = false;
break;
}
}
}
if ok {
for cmd in &step.commands {
log.push(format!("$ {cmd}"));
match run_command(cmd) {
Ok((0, out)) => {
log.extend(out.into_iter().map(|l| format!(" {l}")));
}
Ok((code, out)) => {
log.extend(out.into_iter().map(|l| format!(" {l}")));
log.push(format!("✘ el comando salió con código {code}"));
ok = false;
break;
}
Err(e) => {
log.push(format!("✘ no se pudo ejecutar: {e}"));
ok = false;
break;
}
}
}
}
StepResult { describe: step.describe.clone(), ok, log }
}
/// Aplica los pasos en orden. Se detiene en el primero que falle
/// (semántica `set -e`): los posteriores no se ejecutan.
pub fn apply(steps: &[ApplyStep]) -> ApplyReport {
let mut results = Vec::new();
for step in steps {
let result = apply_step(step);
let failed = !result.ok;
results.push(result);
if failed {
break;
}
}
ApplyReport { results }
}
/// Simula la aplicación: reporta qué archivos y comandos se ejecutarían,
/// sin tocar nada. Seguro para previsualizar.
pub fn dry_run(steps: &[ApplyStep]) -> ApplyReport {
let results = steps
.iter()
.map(|s| {
let mut log = Vec::new();
for f in &s.files {
log.push(format!("escribiría {} ({} bytes)", f.path, f.content.len()));
}
for c in &s.commands {
log.push(format!("$ {c}"));
}
StepResult { describe: s.describe.clone(), ok: true, log }
})
.collect();
ApplyReport { results }
}
#[cfg(test)]
mod tests {
use super::*;
use matilda_apply::FileWrite;
/// Paso que escribe un archivo temporal y corre un comando.
fn step(describe: &str, file: Option<FileWrite>, cmds: &[&str]) -> ApplyStep {
ApplyStep {
describe: describe.into(),
files: file.into_iter().collect(),
commands: cmds.iter().map(|s| s.to_string()).collect(),
}
}
fn temp(name: &str) -> String {
std::env::temp_dir()
.join(format!("matilda-ghost-{}-{name}", std::process::id()))
.to_string_lossy()
.into_owned()
}
#[test]
fn dry_run_touches_nothing() {
let path = temp("dry");
let _ = std::fs::remove_file(&path);
let steps = vec![step(
"crear x",
Some(FileWrite { path: path.clone(), content: "hola".into() }),
&["echo hecho"],
)];
let report = dry_run(&steps);
assert!(report.all_ok());
assert_eq!(report.results.len(), 1);
// dry_run no escribió el archivo.
assert!(!std::path::Path::new(&path).exists());
}
#[test]
fn apply_writes_files_and_runs_commands() {
let path = temp("apply");
let _ = std::fs::remove_file(&path);
let steps = vec![step(
"crear config",
Some(FileWrite { path: path.clone(), content: "contenido".into() }),
&["echo aplicado"],
)];
let report = apply(&steps);
assert!(report.all_ok());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "contenido");
assert!(report.results[0].log.iter().any(|l| l.contains("aplicado")));
let _ = std::fs::remove_file(&path);
}
#[test]
fn apply_stops_at_the_first_failure() {
let steps = vec![
step("ok", None, &["true"]),
step("falla", None, &["exit 7"]),
step("nunca", None, &["echo no-deberia-correr"]),
];
let report = apply(&steps);
// El tercer paso no se ejecutó.
assert_eq!(report.results.len(), 2);
assert!(!report.all_ok());
assert_eq!(report.applied(), 1);
assert!(report.failed().unwrap().describe.contains("falla"));
}
#[test]
fn nonzero_exit_marks_the_step_failed() {
let report = apply(&[step("test", None, &["false"])]);
assert!(!report.results[0].ok);
}
#[test]
fn empty_plan_applies_cleanly() {
let report = apply(&[]);
assert!(report.all_ok());
assert_eq!(report.applied(), 0);
}
}
@@ -0,0 +1,13 @@
[package]
name = "matilda-linker"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "matilda — el enlace SSH: conecta a un servidor y aplica los ApplySteps remotamente, escribiendo archivos y corriendo comandos sobre la conexión multiplexada."
[dependencies]
matilda-apply = { path = "../matilda-apply" }
matilda-ghost = { path = "../matilda-ghost" }
ssh = { workspace = true }
@@ -0,0 +1,9 @@
# matilda-linker
> Enlaza dotfiles de [matilda](../../README.md).
Maneja symlinks de dotfiles desde el repo del usuario a `$HOME`. Detecta conflictos; backup automático antes de pisar.
## Deps
- [`matilda-core`](../matilda-core/README.md)
@@ -0,0 +1,9 @@
# matilda-linker
> Dotfile linker of [matilda](../../README.md).
Manages symlinks from the user's repo to `$HOME`. Detects conflicts; automatic backup before overwriting.
## Deps
- [`matilda-core`](../matilda-core/README.md)
@@ -0,0 +1,154 @@
//! `matilda-linker` — el enlace SSH que aplica un plan en un servidor.
//!
//! El [`Linker`] conecta a un host vía `brahman-ssh-multiplex` y aplica
//! los [`ApplyStep`]s **remotamente**: escribe los archivos (con un
//! heredoc) y corre los comandos, cada uno sobre la conexión SSH
//! multiplexada. Produce el mismo [`ApplyReport`] que `matilda-ghost`,
//! así el consumidor no distingue aplicación local de remota.
//!
//! La prueba real necesita un servidor SSH — se hace fuera del unit
//! test. Lo puro y testeable es la construcción del comando de escritura.
#![forbid(unsafe_code)]
use matilda_apply::{ApplyStep, FileWrite};
use matilda_ghost::{ApplyReport, StepResult};
pub use ssh::{SshAuth, SshConfig, SshError};
use ssh::SshSession;
/// Marcador de heredoc para escribir archivos remotos.
const HEREDOC: &str = "MATILDA_LINKER_EOF";
/// Comando de shell que escribe `f.content` en `f.path` del host remoto.
fn file_write_command(f: &FileWrite) -> String {
format!(
"cat > '{}' <<'{HEREDOC}'\n{}\n{HEREDOC}",
f.path, f.content
)
}
/// Enlace activo a un servidor: una sesión SSH multiplexada.
pub struct Linker {
session: SshSession,
}
impl Linker {
/// Conecta y autentica contra el host descrito por `config`.
pub async fn connect(config: &SshConfig) -> Result<Linker, SshError> {
Ok(Linker { session: SshSession::connect(config).await? })
}
/// Aplica un paso en el host remoto: escribe sus archivos, corre sus
/// comandos. Se detiene en el primer error.
async fn apply_step(&self, step: &ApplyStep) -> StepResult {
let mut log = Vec::new();
let mut ok = true;
for f in &step.files {
match self.session.exec(&file_write_command(f)).await {
Ok(out) if out.exit_code == 0 => log.push(format!("✔ escrito {}", f.path)),
Ok(out) => {
log.push(format!(
"✘ escribir {}: {}",
f.path,
String::from_utf8_lossy(&out.stderr).trim()
));
ok = false;
break;
}
Err(e) => {
log.push(format!("{e}"));
ok = false;
break;
}
}
}
if ok {
for cmd in &step.commands {
log.push(format!("$ {cmd}"));
match self.session.exec(cmd).await {
Ok(out) => {
for l in String::from_utf8_lossy(&out.stdout).lines() {
log.push(format!(" {l}"));
}
for l in String::from_utf8_lossy(&out.stderr).lines() {
log.push(format!(" {l}"));
}
if out.exit_code != 0 {
log.push(format!("✘ el comando salió con código {}", out.exit_code));
ok = false;
break;
}
}
Err(e) => {
log.push(format!("✘ no se pudo ejecutar: {e}"));
ok = false;
break;
}
}
}
}
StepResult { describe: step.describe.clone(), ok, log }
}
/// Ejecuta un comando arbitrario en el host remoto y devuelve su
/// stdout. Útil para discover (p. ej. `docker ps -a --format
/// '{{.Names}}'`) sin abrir un `apply` completo. Si el comando
/// sale con código distinto de 0, retorna el stderr como `Err`.
pub async fn exec(&self, cmd: &str) -> Result<String, SshError> {
let out = self.session.exec(cmd).await?;
if out.exit_code != 0 {
return Err(SshError::Channel(format!(
"exit {}: {}",
out.exit_code,
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Aplica los pasos en orden sobre el host remoto. Se detiene en el
/// primero que falle (semántica `set -e`).
pub async fn apply(&self, steps: &[ApplyStep]) -> ApplyReport {
let mut results = Vec::new();
for step in steps {
let result = self.apply_step(step).await;
let failed = !result.ok;
results.push(result);
if failed {
break;
}
}
ApplyReport { results }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_write_command_uses_a_heredoc() {
let f = FileWrite {
path: "/etc/nginx/sites-enabled/site.conf".into(),
content: "server { listen 80; }".into(),
};
let cmd = file_write_command(&f);
assert!(cmd.starts_with("cat > '/etc/nginx/sites-enabled/site.conf' <<'"));
assert!(cmd.contains("server { listen 80; }"));
assert!(cmd.ends_with(HEREDOC));
}
#[test]
fn ssh_config_is_re_exported() {
// El consumidor arma la conexión sin depender de ssh-multiplex.
let c = SshConfig::new("srv.example", "deploy", SshAuth::Password("x".into()));
assert_eq!(c.host, "srv.example");
}
// La aplicación remota real (`Linker::connect` + `apply`) necesita un
// servidor SSH — se prueba fuera del unit test.
}
@@ -0,0 +1,12 @@
[package]
name = "matilda-plan"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "matilda — reconciliación de estado: compara el inventario deseado con el actual y produce una lista ordenada de acciones que respeta las dependencias."
[dependencies]
matilda-core = { path = "../matilda-core" }
serde = { workspace = true }
@@ -0,0 +1,9 @@
# matilda-plan
> Planificador de diff (actual → deseado) de [matilda](../../README.md).
Toma actual + deseado, produce `Vec<Action>` ordenada por dependencia. Cada `Action` es atómica y reversible.
## Deps
- [`matilda-core`](../matilda-core/README.md), [`matilda-discover`](../matilda-discover/README.md)
@@ -0,0 +1,9 @@
# matilda-plan
> Diff planner (actual → desired) of [matilda](../../README.md).
Takes actual + desired, produces a dependency-ordered `Vec<Action>`. Each `Action` is atomic and reversible.
## Deps
- [`matilda-core`](../matilda-core/README.md), [`matilda-discover`](../matilda-discover/README.md)
@@ -0,0 +1,268 @@
//! `matilda-plan` — reconciliación de estado deseado vs actual.
//!
//! Dado el inventario *actual* de un servidor y el inventario *deseado*,
//! produce la lista de [`Action`]s que lo lleva de uno al otro. El orden
//! respeta las dependencias:
//!
//! 1. crear/actualizar hosts;
//! 2. crear/actualizar contenedores (los vhosts dependen de ellos);
//! 3. crear/actualizar vhosts;
//! 4. eliminar vhosts (antes que sus contenedores);
//! 5. eliminar contenedores;
//! 6. eliminar hosts.
//!
//! Es una función pura y determinista — el mismo par de inventarios da
//! siempre el mismo plan. Aplicarlo (Docker, nginx, SSH) es trabajo de
//! capas superiores.
#![forbid(unsafe_code)]
use matilda_core::Inventory;
use serde::{Deserialize, Serialize};
/// El tipo de recurso sobre el que opera una acción.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Resource {
Host,
Container,
VHost,
}
impl Resource {
fn label(self) -> &'static str {
match self {
Resource::Host => "host",
Resource::Container => "contenedor",
Resource::VHost => "vhost",
}
}
}
/// La operación de una acción.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Op {
Create,
Update,
Remove,
}
impl Op {
fn verb(self) -> &'static str {
match self {
Op::Create => "crear",
Op::Update => "actualizar",
Op::Remove => "eliminar",
}
}
}
/// Una acción del plan: operar sobre un recurso identificado por nombre.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Action {
pub op: Op,
pub resource: Resource,
/// Nombre del recurso — `name` del host/contenedor, `domain` del vhost.
pub name: String,
}
impl Action {
fn new(op: Op, resource: Resource, name: impl Into<String>) -> Self {
Self { op, resource, name: name.into() }
}
/// Descripción legible — `"crear contenedor «web»"`.
pub fn describe(&self) -> String {
format!("{} {} «{}»", self.op.verb(), self.resource.label(), self.name)
}
}
/// El plan de reconciliación: acciones en orden de aplicación.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Plan {
pub actions: Vec<Action>,
}
impl Plan {
/// `true` si no hay nada que cambiar — los inventarios ya coinciden.
pub fn is_empty(&self) -> bool {
self.actions.is_empty()
}
/// Cantidad de acciones.
pub fn len(&self) -> usize {
self.actions.len()
}
/// Cuenta las acciones de una operación dada.
pub fn count(&self, op: Op) -> usize {
self.actions.iter().filter(|a| a.op == op).count()
}
}
/// Calcula el plan que lleva de `current` a `desired`.
pub fn plan(current: &Inventory, desired: &Inventory) -> Plan {
let mut actions: Vec<Action> = Vec::new();
// --- Fase 1: hosts a crear/actualizar ---
for h in desired.hosts() {
match current.host(&h.name) {
None => actions.push(Action::new(Op::Create, Resource::Host, &h.name)),
Some(cur) if cur != h => {
actions.push(Action::new(Op::Update, Resource::Host, &h.name))
}
Some(_) => {}
}
}
// --- Fase 2: contenedores a crear/actualizar ---
for c in desired.containers() {
match current.container(&c.name) {
None => actions.push(Action::new(Op::Create, Resource::Container, &c.name)),
Some(cur) if cur != c => {
actions.push(Action::new(Op::Update, Resource::Container, &c.name))
}
Some(_) => {}
}
}
// --- Fase 3: vhosts a crear/actualizar ---
for v in desired.vhosts() {
match current.vhost(&v.domain) {
None => actions.push(Action::new(Op::Create, Resource::VHost, &v.domain)),
Some(cur) if cur != v => {
actions.push(Action::new(Op::Update, Resource::VHost, &v.domain))
}
Some(_) => {}
}
}
// --- Fase 4: vhosts a eliminar (antes que sus contenedores) ---
for v in current.vhosts() {
if desired.vhost(&v.domain).is_none() {
actions.push(Action::new(Op::Remove, Resource::VHost, &v.domain));
}
}
// --- Fase 5: contenedores a eliminar ---
for c in current.containers() {
if desired.container(&c.name).is_none() {
actions.push(Action::new(Op::Remove, Resource::Container, &c.name));
}
}
// --- Fase 6: hosts a eliminar ---
for h in current.hosts() {
if desired.host(&h.name).is_none() {
actions.push(Action::new(Op::Remove, Resource::Host, &h.name));
}
}
Plan { actions }
}
#[cfg(test)]
mod tests {
use super::*;
use matilda_core::{Container, Host, VHost};
#[test]
fn empty_to_empty_is_a_noop() {
let p = plan(&Inventory::new(), &Inventory::new());
assert!(p.is_empty());
}
#[test]
fn fresh_inventory_is_all_creates() {
let mut desired = Inventory::new();
desired.add_host(Host::new("edge", "10.0.0.1"));
desired.add_container(Container::new("web", "nginx"));
desired.add_vhost(VHost::to_container("site.com", "web", 80));
let p = plan(&Inventory::new(), &desired);
assert_eq!(p.count(Op::Create), 3);
assert_eq!(p.count(Op::Remove), 0);
}
#[test]
fn unchanged_inventory_yields_no_actions() {
let mut inv = Inventory::new();
inv.add_container(Container::new("web", "nginx:1.27"));
let p = plan(&inv, &inv.clone());
assert!(p.is_empty());
}
#[test]
fn changed_image_is_an_update() {
let mut current = Inventory::new();
current.add_container(Container::new("web", "nginx:1.26"));
let mut desired = Inventory::new();
desired.add_container(Container::new("web", "nginx:1.27"));
let p = plan(&current, &desired);
assert_eq!(p.actions, vec![Action::new(Op::Update, Resource::Container, "web")]);
}
#[test]
fn dropped_resources_become_removes() {
let mut current = Inventory::new();
current.add_container(Container::new("old", "img"));
current.add_vhost(VHost::to_container("old.com", "old", 80));
let p = plan(&current, &Inventory::new());
assert_eq!(p.count(Op::Remove), 2);
}
#[test]
fn vhost_removal_precedes_container_removal() {
// Un vhost debe eliminarse antes que el contenedor que lo sirve.
let mut current = Inventory::new();
current.add_container(Container::new("web", "nginx"));
current.add_vhost(VHost::to_container("site.com", "web", 80));
let p = plan(&current, &Inventory::new());
let vhost_pos = p
.actions
.iter()
.position(|a| a.resource == Resource::VHost)
.unwrap();
let cont_pos = p
.actions
.iter()
.position(|a| a.resource == Resource::Container)
.unwrap();
assert!(vhost_pos < cont_pos);
}
#[test]
fn container_creation_precedes_vhost_creation() {
let mut desired = Inventory::new();
desired.add_container(Container::new("web", "nginx"));
desired.add_vhost(VHost::to_container("site.com", "web", 80));
let p = plan(&Inventory::new(), &desired);
let cont_pos = p
.actions
.iter()
.position(|a| a.resource == Resource::Container)
.unwrap();
let vhost_pos = p
.actions
.iter()
.position(|a| a.resource == Resource::VHost)
.unwrap();
assert!(cont_pos < vhost_pos);
}
#[test]
fn plan_is_deterministic() {
let mut current = Inventory::new();
current.add_container(Container::new("a", "img:1"));
let mut desired = Inventory::new();
desired.add_container(Container::new("a", "img:2"));
desired.add_container(Container::new("b", "img:1"));
assert_eq!(plan(&current, &desired), plan(&current, &desired));
}
#[test]
fn describe_is_human_readable() {
let a = Action::new(Op::Create, Resource::Container, "web");
assert_eq!(a.describe(), "crear contenedor «web»");
}
}
@@ -0,0 +1,25 @@
[package]
name = "shuma-card"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Tipos de shipote: WorkspaceSpec, PipelineSpec, CommandRef, FlowEdge. Compilan a Cards de card-core."
[dependencies]
card-core = { workspace = true }
# Orquestador único de la suite (Linux): shuma compila sus specs a `Intent`
# de sandokan en vez de manejar su propio ciclo de vida. Ver SDD de sandokan.
sandokan-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
[dev-dependencies]
# Prueba end-to-end del puente: spec de shuma → Intent → proceso aislado real.
sandokan-local = { workspace = true }
tokio = { workspace = true }
@@ -0,0 +1,9 @@
# shuma-card
> Card escritorio de [shuma](../../README.md).
Stat-card con sesiones activas + última actividad. Click abre el shell.
## Deps
- [`shuma-core`](../shuma-core/README.md), [`llimphi-widget-stat-card`](../../../llimphi/widgets/stat-card/README.md)
@@ -0,0 +1,9 @@
# shuma-card
> Desktop card of [shuma](../../README.md).
Stat-card with active sessions + last activity. Click opens the shell.
## Deps
- [`shuma-core`](../shuma-core/README.md), [`llimphi-widget-stat-card`](../../../llimphi/widgets/stat-card/README.md)
@@ -0,0 +1,774 @@
//! `shuma-card` — tipos del runtime shuma.
//!
//! Tres entidades nuevas encima del `brahman-card::Card`:
//!
//! - [`WorkspaceSpec`] — espacio aislado raíz con su propio `SomaSpec`.
//! - [`CommandRef`] — un comando dentro de un workspace.
//! - [`PipelineSpec`] — DAG de `CommandRef` conectados por `FlowEdge`.
//!
//! Cada `WorkspaceSpec`/`CommandRef` se **compila** a una o varias
//! [`card_core::Card`] que el daemon entrega al [`Incarnator`] de
//! `ente-incarnate`. Esto preserva el contrato canónico del fractal.
#![forbid(unsafe_code)]
use card_core::{Card, Payload, Permissions, SomaSpec, Supervision};
use sandokan_core::{Intent, IsolationLevel};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
use ulid::Ulid;
// =====================================================================
// Identidades
// =====================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorkspaceId(pub Ulid);
impl WorkspaceId {
pub fn new() -> Self {
Self(Ulid::new())
}
}
impl Default for WorkspaceId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for WorkspaceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PipelineId(pub Ulid);
impl PipelineId {
pub fn new() -> Self {
Self(Ulid::new())
}
}
impl Default for PipelineId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for PipelineId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
// =====================================================================
// Workspace
// =====================================================================
/// Espacio aislado de shuma. Es la raíz de aislamiento — cualquier comando
/// que corre dentro hereda restricciones y no puede aflojarlas.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceSpec {
pub label: String,
/// Aislamiento del workspace mismo (cuando se materializa como Card raíz).
#[serde(default)]
pub soma: SomaSpec,
/// Permisos máximos para hijas. Hijas pueden bajar pero no subir.
#[serde(default)]
pub permissions: Permissions,
/// `None` = vive hasta `stop`. `Some(d)` = el daemon lo termina tras d.
#[serde(default, with = "opt_duration_millis")]
pub ttl: Option<Duration>,
/// Slots de flow pre-declarados. Limitan qué consumidores externos al
/// workspace pueden empatar contra los productores internos.
#[serde(default)]
pub flow_dirs: Vec<FlowSlot>,
/// Política al terminar el workspace.
#[serde(default)]
pub on_exit: ExitPolicy,
/// Política de enforcement automático cuando un recurso excede su
/// rlimit declarado en `soma.rlimits`. Default = sólo accounting
/// (None) — el quota report sigue funcionando, pero no hay kill.
#[serde(default)]
pub quota_enforce: QuotaEnforcement,
}
/// Acción cuando un recurso excede su límite. Aplica por recurso (mem,
/// nproc, ...).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum QuotaAction {
/// Sólo accounting: la breach aparece en `workspace_quota`.
#[default]
None,
/// Loguear la breach (info-level del daemon).
Log,
/// Matar todos los comandos vivos del workspace (SIGKILL, sin grace).
Kill,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QuotaEnforcement {
#[serde(default)]
pub mem: QuotaAction,
#[serde(default)]
pub nproc: QuotaAction,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowSlot {
pub name: String,
pub direction: FlowDirection,
/// Si `Workspace`, sólo otros nodos del mismo workspace pueden empatar.
/// Si `Public`, el broker global puede emparejar.
#[serde(default)]
pub scope: FlowScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FlowDirection {
Input,
Output,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FlowScope {
#[default]
Workspace,
Public,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExitPolicy {
/// Reapear procesos hijos y descartar estado.
#[default]
Reap,
/// Mantener el workspace en `stopped` para inspección.
Keep,
/// Tomar snapshot del estado (para restart posterior).
Snapshot,
}
mod opt_duration_millis {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
d.map(|x| x.as_millis() as u64).serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
let v: Option<u64> = Option::deserialize(d)?;
Ok(v.map(Duration::from_millis))
}
}
// =====================================================================
// CommandRef
// =====================================================================
/// Un comando que vive dentro de un workspace. Se compila a una `Card` con
/// `pin_to` apuntando al workspace padre (label) y su `SomaSpec`
/// intersectado con el del workspace.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandRef {
pub label: String,
pub payload: Payload,
/// SomaSpec del comando. El compilador lo intersecta con el del workspace.
#[serde(default)]
pub soma: SomaSpec,
/// Inputs/outputs tipados (mismos `Flow` de brahman-card).
#[serde(default)]
pub flows: card_core::Flows,
/// Política de supervisión. Default `OneShot` (un comando se ejecuta y muere).
#[serde(default = "default_oneshot")]
pub supervision: Supervision,
}
fn default_oneshot() -> Supervision {
Supervision::OneShot
}
// =====================================================================
// Pipeline
// =====================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineSpec {
pub label: String,
pub workspace: WorkspaceId,
pub nodes: Vec<CommandRef>,
#[serde(default)]
pub edges: Vec<FlowEdge>,
#[serde(default)]
pub discern: DiscernPolicy,
/// Si `true` y cualquier comando del pipeline termina con exit!=0,
/// el daemon relaunch el pipeline ENTERO (stop + nuevo run_pipeline).
/// Útil para pipelines de procesamiento continuo.
#[serde(default)]
pub restart_on_failure: bool,
/// Backoff inicial entre restarts (ms). Crece exponencialmente
/// hasta `restart_max_backoff_ms`. Default 200ms = ~5 restarts/s
/// inicial, escalando rápido.
#[serde(default = "default_restart_backoff")]
pub restart_backoff_ms: u64,
/// Backoff máximo (ms). Default 30s. El backoff no crece más allá.
#[serde(default = "default_restart_max_backoff")]
pub restart_max_backoff_ms: u64,
/// Máximo de restarts antes de dar up. `0` = infinito. Default 0.
/// Útil para fail-loud cuando un pipeline siempre falla.
#[serde(default)]
pub restart_max: u32,
}
fn default_restart_backoff() -> u64 {
200
}
fn default_restart_max_backoff() -> u64 {
30_000
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowEdge {
/// Índice en `PipelineSpec.nodes` del productor.
pub from: usize,
/// Nombre del Flow output del productor.
pub from_output: String,
/// Índice en `PipelineSpec.nodes` del consumidor.
pub to: usize,
/// Nombre del Flow input del consumidor.
pub to_input: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscernPolicy {
/// Bytes a samplear por flow para el discernidor. Default 4 KiB.
#[serde(default = "default_sample_bytes")]
pub sample_bytes: usize,
/// Si `true`, enriquece la Card del producer con el TypeRef detectado.
#[serde(default = "default_true")]
pub enrich_producer: bool,
/// Chunks que el FlowChannel guarda en replay buffer para subscribers
/// tarde. Default 32. Subir si los productores escriben en ráfagas y
/// querés que los consumidores tardíos vean toda la salida.
#[serde(default = "default_replay_chunks")]
pub replay_chunks: usize,
/// Tope adicional por **bytes** acumulados en el replay buffer. Lo
/// que se exceda primero (chunks o bytes) drop-ea el chunk más viejo.
/// `0` = sin tope por bytes (sólo aplica `replay_chunks`). Útil para
/// productores con chunks de tamaño variable.
#[serde(default)]
pub replay_bytes: usize,
/// Rate-limit del flow channel (bytes/s). `0` = sin límite. Si está
/// definido, el splitter sleeps proporcional al tamaño del chunk
/// antes de re-broadcastear. Protege subscribers lentos.
#[serde(default)]
pub max_bytes_per_sec: u64,
}
impl Default for DiscernPolicy {
fn default() -> Self {
Self {
sample_bytes: default_sample_bytes(),
enrich_producer: default_true(),
replay_chunks: default_replay_chunks(),
replay_bytes: 0,
max_bytes_per_sec: 0,
}
}
}
fn default_sample_bytes() -> usize {
4096
}
fn default_true() -> bool {
true
}
fn default_replay_chunks() -> usize {
32
}
// =====================================================================
// Compilación a Card
// =====================================================================
#[derive(Debug, Error)]
pub enum CompileError {
#[error("workspace label vacío")]
EmptyWorkspaceLabel,
#[error("comando con label vacío en posición {0}")]
EmptyCommandLabel(usize),
#[error("edge fuera de rango: from={from}, to={to}, nodes={nodes}")]
EdgeOutOfBounds { from: usize, to: usize, nodes: usize },
}
impl WorkspaceSpec {
/// Compila el WorkspaceSpec a una Card raíz que el Incarnator puede
/// encarnar. Usa `Payload::Virtual` (el workspace no es un proceso por
/// sí solo; sólo aloja hijos).
pub fn to_card(&self, id: WorkspaceId) -> Result<Card, CompileError> {
if self.label.trim().is_empty() {
return Err(CompileError::EmptyWorkspaceLabel);
}
let mut c = Card::new(format!("shuma.workspace.{}", self.label));
c.id = id.0;
c.soma = self.soma.clone();
c.permissions = self.permissions.clone();
c.payload = Payload::Virtual;
c.supervision = Supervision::OneShot;
Ok(c)
}
/// Puente al orquestador único (sandokan): compila el workspace a una
/// `Intent` que cualquier `sandokan_core::Engine` (LocalEngine en este
/// host, DaemonEngine o RemoteEngine) puede encarnar. Es la Card
/// `Virtual` contenedora — aloja comandos hijos que se le unen.
pub fn to_intent(
&self,
id: WorkspaceId,
isolation: IsolationLevel,
) -> Result<Intent, CompileError> {
Ok(Intent::new(self.to_card(id)?).with_isolation(isolation))
}
/// Puente "ambiente = un shell aislado" (slice 1: *solo aislarlo*).
/// A diferencia de [`Self::to_card`] (Card `Virtual` contenedora), esto
/// produce una Card **Native** que ejecuta `exec`/`argv` tomando el
/// `soma` del workspace como su propio aislamiento — el shell vive
/// directamente dentro de los namespaces del ambiente, sin un paso de
/// "unirse" a un contenedor aparte. La devuelve como `Intent` lista
/// para `Engine::run`.
pub fn shell_intent(
&self,
id: WorkspaceId,
exec: impl Into<String>,
argv: Vec<String>,
isolation: IsolationLevel,
) -> Result<Intent, CompileError> {
if self.label.trim().is_empty() {
return Err(CompileError::EmptyWorkspaceLabel);
}
let mut c = Card::new(format!("shuma.workspace.{}", self.label));
c.id = id.0;
c.soma = self.soma.clone();
c.permissions = self.permissions.clone();
c.payload = Payload::Native {
exec: exec.into(),
argv,
envp: vec![],
};
c.supervision = Supervision::OneShot;
Ok(Intent::new(c).with_isolation(isolation))
}
}
impl CommandRef {
/// Compila un CommandRef a Card hija de un workspace. La Card resultante
/// referencia al workspace por label en `pin_to` de cada Flow.
pub fn to_card(&self, idx: usize, workspace_label: &str) -> Result<Card, CompileError> {
if self.label.trim().is_empty() {
return Err(CompileError::EmptyCommandLabel(idx));
}
let mut c = Card::new(format!("shuma.cmd.{}.{}", workspace_label, self.label));
c.payload = self.payload.clone();
c.soma = intersect_soma(&self.soma, /*workspace*/ &SomaSpec::default());
c.supervision = self.supervision.clone();
c.flow = self.flows.clone();
// pin_to del workspace en cada Flow input/output → el broker prefiere
// resolver dentro del mismo workspace cuando hay candidatos múltiples.
let pin = format!("shuma.workspace.{}", workspace_label);
for f in c.flow.input.iter_mut().chain(c.flow.output.iter_mut()) {
if f.pin_to.is_none() {
f.pin_to = Some(pin.clone());
}
}
Ok(c)
}
}
/// Intersección conservadora: si el workspace pidió aislamiento, la hija
/// también lo tiene (no puede aflojar). Si la hija pidió aislamiento extra,
/// se respeta.
fn intersect_soma(child: &SomaSpec, ws: &SomaSpec) -> SomaSpec {
let mut out = child.clone();
out.namespaces.mount |= ws.namespaces.mount;
out.namespaces.pid |= ws.namespaces.pid;
out.namespaces.net |= ws.namespaces.net;
out.namespaces.uts |= ws.namespaces.uts;
out.namespaces.ipc |= ws.namespaces.ipc;
out.namespaces.user |= ws.namespaces.user;
out.namespaces.cgroup |= ws.namespaces.cgroup;
// rlimits: el menor (más restrictivo) gana.
out.rlimits.mem_bytes = min_opt(out.rlimits.mem_bytes, ws.rlimits.mem_bytes);
out.rlimits.nproc = min_opt(out.rlimits.nproc, ws.rlimits.nproc);
out.rlimits.nofile = min_opt(out.rlimits.nofile, ws.rlimits.nofile);
out
}
fn min_opt<T: Ord + Copy>(a: Option<T>, b: Option<T>) -> Option<T> {
match (a, b) {
(Some(x), Some(y)) => Some(x.min(y)),
(Some(x), None) | (None, Some(x)) => Some(x),
(None, None) => None,
}
}
impl PipelineSpec {
pub fn validate(&self) -> Result<(), CompileError> {
let n = self.nodes.len();
for (i, c) in self.nodes.iter().enumerate() {
if c.label.trim().is_empty() {
return Err(CompileError::EmptyCommandLabel(i));
}
}
for e in &self.edges {
if e.from >= n || e.to >= n {
return Err(CompileError::EdgeOutOfBounds {
from: e.from,
to: e.to,
nodes: n,
});
}
}
Ok(())
}
}
// =====================================================================
// I/O conveniencia (TOML + JSON)
// =====================================================================
#[derive(Debug, Error)]
pub enum LoadError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("toml: {0}")]
Toml(#[from] toml::de::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("format desconocido (esperado .toml o .json)")]
UnknownFormat,
}
pub fn load_workspace_spec(path: &std::path::Path) -> Result<WorkspaceSpec, LoadError> {
let raw = std::fs::read_to_string(path)?;
match path.extension().and_then(|s| s.to_str()) {
Some("toml") => Ok(toml::from_str(&raw)?),
Some("json") => Ok(serde_json::from_str(&raw)?),
_ => Err(LoadError::UnknownFormat),
}
}
pub fn load_pipeline_spec(path: &std::path::Path) -> Result<PipelineSpec, LoadError> {
let raw = std::fs::read_to_string(path)?;
match path.extension().and_then(|s| s.to_str()) {
Some("toml") => Ok(toml::from_str(&raw)?),
Some("json") => Ok(serde_json::from_str(&raw)?),
_ => Err(LoadError::UnknownFormat),
}
}
/// Sustituye `${KEY}` en todos los strings del spec por el valor de
/// `vars["KEY"]`. Variables sin match quedan intactas (no se borra el
/// placeholder — útil para detectar olvidos).
///
/// Walk recursivo sobre la representación JSON intermedia para cubrir
/// labels, argv, envp, paths y cualquier String del schema.
pub fn substitute_vars(
spec: &PipelineSpec,
vars: &std::collections::HashMap<String, String>,
) -> Result<PipelineSpec, serde_json::Error> {
if vars.is_empty() {
return Ok(spec.clone());
}
let mut v = serde_json::to_value(spec)?;
walk_subst(&mut v, vars);
serde_json::from_value(v)
}
fn walk_subst(v: &mut serde_json::Value, vars: &std::collections::HashMap<String, String>) {
match v {
serde_json::Value::String(s) => {
*s = subst_str(s, vars);
}
serde_json::Value::Array(arr) => {
for item in arr {
walk_subst(item, vars);
}
}
serde_json::Value::Object(obj) => {
for (_, val) in obj.iter_mut() {
walk_subst(val, vars);
}
}
_ => {}
}
}
fn subst_str(s: &str, vars: &std::collections::HashMap<String, String>) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
// Buscar el cierre `}`.
if let Some(close) = bytes[i + 2..].iter().position(|&b| b == b'}') {
let key = std::str::from_utf8(&bytes[i + 2..i + 2 + close]).unwrap_or("");
if let Some(val) = vars.get(key) {
out.push_str(val);
i += 2 + close + 1;
continue;
}
}
}
out.push(bytes[i] as char);
i += 1;
}
out
}
#[cfg(test)]
mod subst_tests {
use super::*;
use std::collections::HashMap;
#[test]
fn substitute_in_argv_and_label() {
let mut vars = HashMap::new();
vars.insert("MSG".into(), "hola-mundo".into());
vars.insert("LABEL".into(), "renamed".into());
let spec = PipelineSpec {
label: "p-${LABEL}".into(),
workspace: WorkspaceId::new(),
nodes: vec![CommandRef {
label: "node-${LABEL}".into(),
payload: Payload::Native {
exec: "/bin/echo".into(),
argv: vec!["${MSG}".into()],
envp: vec![],
},
soma: Default::default(),
flows: Default::default(),
supervision: Supervision::OneShot,
}],
edges: vec![],
discern: DiscernPolicy::default(),
restart_on_failure: false,
restart_backoff_ms: 200,
restart_max_backoff_ms: 30_000,
restart_max: 0,
};
let out = substitute_vars(&spec, &vars).unwrap();
assert_eq!(out.label, "p-renamed");
assert_eq!(out.nodes[0].label, "node-renamed");
match &out.nodes[0].payload {
Payload::Native { argv, .. } => assert_eq!(argv[0], "hola-mundo"),
_ => panic!("wrong payload"),
}
}
#[test]
fn unknown_var_left_intact() {
let vars = HashMap::new();
let spec = PipelineSpec {
label: "p-${UNDEFINED}".into(),
workspace: WorkspaceId::new(),
nodes: vec![],
edges: vec![],
discern: DiscernPolicy::default(),
restart_on_failure: false,
restart_backoff_ms: 200,
restart_max_backoff_ms: 30_000,
restart_max: 0,
};
let out = substitute_vars(&spec, &vars).unwrap();
assert_eq!(out.label, "p-${UNDEFINED}");
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_workspace() -> WorkspaceSpec {
WorkspaceSpec {
label: "demo".into(),
soma: SomaSpec::default(),
permissions: Permissions::default(),
ttl: Some(Duration::from_secs(60)),
flow_dirs: vec![FlowSlot {
name: "out".into(),
direction: FlowDirection::Output,
scope: FlowScope::Public,
}],
on_exit: ExitPolicy::Reap,
quota_enforce: Default::default(),
}
}
#[test]
fn workspace_toml_roundtrip() {
let ws = sample_workspace();
let s = toml::to_string(&ws).unwrap();
let back: WorkspaceSpec = toml::from_str(&s).unwrap();
assert_eq!(back.label, ws.label);
assert_eq!(back.ttl, ws.ttl);
assert_eq!(back.flow_dirs.len(), 1);
}
#[test]
fn workspace_json_roundtrip() {
let ws = sample_workspace();
let s = serde_json::to_string(&ws).unwrap();
let back: WorkspaceSpec = serde_json::from_str(&s).unwrap();
assert_eq!(back.label, ws.label);
}
#[test]
fn workspace_compiles_to_card() {
let ws = sample_workspace();
let id = WorkspaceId::new();
let c = ws.to_card(id).unwrap();
assert_eq!(c.id, id.0);
assert!(c.label.starts_with("shuma.workspace."));
assert!(matches!(c.payload, Payload::Virtual));
}
#[test]
fn empty_label_rejected() {
let mut ws = sample_workspace();
ws.label = String::new();
assert!(ws.to_card(WorkspaceId::new()).is_err());
}
#[test]
fn workspace_compiles_to_intent_virtual() {
let ws = sample_workspace();
let intent = ws
.to_intent(WorkspaceId::new(), IsolationLevel::Standard)
.unwrap();
assert!(matches!(intent.card.payload, Payload::Virtual));
assert_eq!(intent.context.isolation, Some(IsolationLevel::Standard));
}
#[test]
fn shell_intent_is_native_and_inherits_workspace_soma() {
let mut ws = sample_workspace();
ws.soma.namespaces.user = true;
ws.soma.namespaces.pid = true;
let intent = ws
.shell_intent(
WorkspaceId::new(),
"/bin/sh",
vec!["-c".into(), "true".into()],
IsolationLevel::Standard,
)
.unwrap();
match &intent.card.payload {
Payload::Native { exec, .. } => assert_eq!(exec, "/bin/sh"),
other => panic!("esperaba Native, fue {other:?}"),
}
assert!(
intent.card.soma.namespaces.pid,
"el shell del ambiente hereda el soma del workspace"
);
}
/// Unificación del orquestador: un `WorkspaceSpec` de shuma se materializa
/// como un proceso aislado real **a través del `Engine` de sandokan**
/// (LocalEngine), sin que shuma maneje su propio ciclo de vida. El shell
/// sale 0 sólo si vio PID 1, probando que el aislamiento del ambiente se
/// aplicó end-to-end por el camino unificado.
#[tokio::test]
async fn workspace_shell_runs_isolated_via_sandokan_engine() {
use card_core::NamespaceSet;
use sandokan_core::Engine;
use sandokan_local::LocalEngine;
let mut ws = sample_workspace();
ws.soma.namespaces = NamespaceSet {
user: true,
pid: true,
mount: true,
uts: true,
ipc: true,
net: false,
cgroup: false,
};
let intent = ws
.shell_intent(
WorkspaceId::new(),
"/bin/sh",
vec!["-c".into(), "test $$ -eq 1".into()],
IsolationLevel::Standard,
)
.unwrap();
let id = intent.card_id();
let engine = LocalEngine::new();
engine.run(intent).await.expect("run vía sandokan");
tokio::time::sleep(Duration::from_millis(300)).await;
let st = engine.status(id).await.expect("status");
assert!(st.is_terminal(), "el shell debió terminar, fue {st:?}");
assert!(
!st.is_failure(),
"exit 0 ⇒ el shell vio PID 1 (ambiente aislado). Estado: {st:?}"
);
}
#[test]
fn pipeline_validates_edges() {
let p = PipelineSpec {
label: "p".into(),
workspace: WorkspaceId::new(),
nodes: vec![CommandRef {
label: "a".into(),
payload: Payload::Virtual,
soma: SomaSpec::default(),
flows: card_core::Flows::default(),
supervision: Supervision::OneShot,
}],
edges: vec![FlowEdge {
from: 0,
from_output: "x".into(),
to: 5,
to_input: "y".into(),
}],
discern: DiscernPolicy::default(),
restart_on_failure: false,
restart_backoff_ms: 200,
restart_max_backoff_ms: 30_000,
restart_max: 0,
};
assert!(p.validate().is_err());
}
#[test]
fn intersect_soma_takes_more_restrictive() {
let mut child = SomaSpec::default();
child.rlimits.mem_bytes = Some(1_000_000);
let mut ws = SomaSpec::default();
ws.rlimits.mem_bytes = Some(500_000);
ws.namespaces.user = true;
let r = intersect_soma(&child, &ws);
assert_eq!(r.rlimits.mem_bytes, Some(500_000));
assert!(r.namespaces.user);
}
}
@@ -0,0 +1,17 @@
[package]
name = "shuma-config"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "shuma — fichero de configuración (.shumarc.toml): aliases, prompt, env, dedup/captura por sesión."
[dependencies]
serde = { workspace = true }
toml = { workspace = true }
directories = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
@@ -0,0 +1,9 @@
# shuma-config
> Config del shell de [shuma](../../README.md).
Aliases, env, prompt template, atajos, plugins. TOML serializable. Recarga en runtime sin reiniciar la sesión.
## Deps
- [`shuma-core`](../shuma-core/README.md), `serde`, `toml`
@@ -0,0 +1,9 @@
# shuma-config
> Shell config of [shuma](../../README.md).
Aliases, env, prompt template, shortcuts, plugins. TOML serializable. Runtime reload without restarting the session.
## Deps
- [`shuma-core`](../shuma-core/README.md), `serde`, `toml`
@@ -0,0 +1,29 @@
# ============================================================
# cargo.toml — completions extra para `cargo` en shuma-shell.
#
# Copiá este fichero a `~/.config/shuma/completions/cargo.toml`
# (creando el directorio si no existe). Los flags listados se SUMAN al
# catálogo built-in de shuma-line; no lo reemplazan. Ideal para flags
# personalizados o nuevos que aún no estén en el catálogo.
#
# Convenciones:
# - Un flag por entrada; el motor de completion los filtra por prefijo.
# - Si el flag espera valor, terminá en `=` (p.ej. `--manifest-path=`):
# tras `=` shuma-shell pasa a completar paths.
# - El array `flags` es lo único soportado hoy; en el futuro se
# sumarán `subcommands` y `args` con tipo (path/host/etc.).
# ============================================================
flags = [
"--target-dir=",
"--manifest-path=",
"--profile=",
"--config=",
"--build-plan",
"--message-format=human",
"--message-format=json",
"--message-format=short",
"--timings",
"--unit-graph",
"-Z",
]
@@ -0,0 +1,62 @@
# ============================================================
# shumarc.toml — configuración personal de `shuma-shell`.
#
# Copiá este fichero a `~/.config/shuma/shumarc.toml` (o el
# equivalente XDG que use tu SO) y editá lo que quieras. Cualquier
# sección omitida cae a los valores por defecto.
# ============================================================
# ---- Aliases ----
# La primera palabra de la línea de comando se reemplaza por el
# cuerpo si coincide con un alias. No hay parámetros posicionales
# ni recursión: lo que devuelve el alias es lo que se ejecuta.
[aliases]
ll = "ls -la --color=auto"
la = "ls -A --color=auto"
gs = "git status --short"
gl = "git log --oneline --graph --decorate"
gd = "git diff"
cb = "cargo build"
ct = "cargo test"
cr = "cargo run"
# ---- Variables de entorno ----
# Se aplican al proceso del shell al arrancar; los hijos las heredan.
[env]
EDITOR = "hx"
PAGER = "less -R"
# ---- Prompt ----
# Segmentos en orden. Tokens soportados:
# "cwd" — directorio actual (con `~` corto si es $HOME).
# "git" — rama actual, si estamos en un repo.
# "exit" — código de salida del último comando, si fue ≠ 0.
# "time" — HH:MM:SS local.
# <otro> — literal, se muestra tal cual (útil como separador).
[prompt]
segments = ["cwd", "git", "exit"]
# ---- Historial durable ----
# Política de dedup al persistir en ~/.local/share/shuma/history.jsonl:
# "none" — guarda todo.
# "ignore_consecutive" — descarta repeticiones inmediatas (default).
# "erase_dups" — al ver un duplicado, borra las copias previas.
[history]
dedup = "ignore_consecutive"
# ---- Captura de salida por sesión ----
# `limit_mb = 0` desactiva el tope; `spill = true` vuelca al disco la
# salida que excede el tope (útil para builds gigantes).
[capture]
limit_mb = 8
spill = false
# ---- Completion de flags ----
# El catálogo built-in de shuma-line cubre ~40 comandos típicos. Para
# ampliarlo, dejá un archivo por comando en
# `$XDG_CONFIG_HOME/shuma/completions/<cmd>.toml` con la forma:
#
# flags = ["--mi-flag", "--otro=", "-x"]
#
# Los flags terminados en `=` activan completion de path tras el `=`.
# Ver `completions.example/cargo.toml` en el repo para un caso real.
@@ -0,0 +1,474 @@
//! `shuma-config` — el fichero de configuración personal del shell.
//!
//! Se carga al arrancar desde `$XDG_CONFIG_HOME/shuma/shumarc.toml`
//! (típicamente `~/.config/shuma/shumarc.toml` en Linux). Si no existe
//! o no se pudo parsear, el shell arranca con [`Config::default`] —
//! aquí no hay nada crítico, sólo preferencias del usuario.
//!
//! Esquema mínimo:
//!
//! ```toml
//! # ---- Aliases ----
//! # Se expanden ANTES del tokenizer: la primera palabra de la línea,
//! # si coincide, se reemplaza por el cuerpo.
//! [aliases]
//! ll = "ls -la"
//! gs = "git status --short"
//!
//! # ---- Variables de entorno ----
//! # Se exportan al proceso del shell al cargar; los procesos hijos las
//! # heredan.
//! [env]
//! EDITOR = "hx"
//!
//! # ---- Prompt ----
//! # Segmentos en orden. Tokens soportados:
//! # "cwd", "git", "exit", "time", o cualquier literal.
//! [prompt]
//! segments = ["cwd", "git", "exit"]
//!
//! # ---- Historial durable ----
//! [history]
//! dedup = "ignore_consecutive" # none | ignore_consecutive | erase_dups
//!
//! # ---- Captura de salida ----
//! [capture]
//! limit_mb = 8
//! spill = false
//! ```
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Política de deduplicación, paralela a la de `shuma-history` pero
/// codificada como string en el fichero TOML para que el rc sea legible.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DedupPolicy {
None,
#[default]
IgnoreConsecutive,
EraseDups,
}
/// Configuración del historial durable.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct HistoryConfig {
#[serde(default)]
pub dedup: DedupPolicy,
}
/// Configuración de la política de captura de salida por sesión.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CaptureConfig {
/// Tope en MiB; `0` = sin tope.
#[serde(default = "default_limit_mb")]
pub limit_mb: usize,
/// Si la salida que excede el tope se vuelca a disco.
#[serde(default)]
pub spill: bool,
}
fn default_limit_mb() -> usize {
8
}
impl Default for CaptureConfig {
fn default() -> Self {
Self { limit_mb: 8, spill: false }
}
}
/// Configuración del prompt — segmentos en orden.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptConfig {
#[serde(default = "default_segments")]
pub segments: Vec<String>,
}
fn default_segments() -> Vec<String> {
vec!["cwd".into()]
}
impl Default for PromptConfig {
fn default() -> Self {
Self { segments: default_segments() }
}
}
/// Configuración completa cargada del `.shumarc.toml`.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Config {
/// Aliases: la primera palabra de una línea se reemplaza por el cuerpo.
#[serde(default)]
pub aliases: HashMap<String, String>,
/// Variables de entorno a exportar al proceso del shell al cargar.
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub prompt: PromptConfig,
#[serde(default)]
pub history: HistoryConfig,
#[serde(default)]
pub capture: CaptureConfig,
}
impl Config {
/// Ruta por defecto: `$XDG_CONFIG_HOME/shuma/shumarc.toml`. `None` si
/// el SO no expone un directorio de configuración.
pub fn default_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "shuma")
.map(|d| d.config_dir().join("shumarc.toml"))
}
/// Directorio donde el shell busca completions extendidas:
/// `$XDG_CONFIG_HOME/shuma/completions/`. Cada archivo `<cmd>.toml`
/// declara las flags de un comando — el shell las suma a la tabla
/// estática de [`shuma_line::flag_hints`].
pub fn completions_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "shuma")
.map(|d| d.config_dir().join("completions"))
}
/// Carga la configuración del path indicado. Si el fichero no existe
/// devuelve [`Config::default`] sin error (caso normal en arranque
/// limpio).
pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let path = path.as_ref();
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(path)
.map_err(|e| ConfigError::Io(path.to_path_buf(), e))?;
toml::from_str(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))
}
/// Carga la configuración del path por defecto. Errores blandos
/// (parse, IO) se devuelven; ausencia del fichero da `default`.
pub fn load_default() -> Result<Self, ConfigError> {
match Self::default_path() {
Some(p) => Self::load(p),
None => Ok(Self::default()),
}
}
/// Aplica las variables de entorno declaradas al proceso actual.
/// Pensado para llamarse una vez al arrancar el shell; los procesos
/// hijos heredan el entorno y verán los valores.
pub fn apply_env(&self) {
for (k, v) in &self.env {
// SAFETY: setenv no es seguro en presencia de hilos concurrentes
// que lean getenv. El shell la llama una vez en el hilo principal,
// antes de spawnear ningún subproceso, así que es válido.
// SAFETY (Rust 2024): `set_var` es unsafe sólo bajo
// edición 2024; en 2021 sigue siendo seguro.
std::env::set_var(k, v);
}
}
/// Expande aliases en una línea: si la **primera palabra** coincide
/// con un alias, se reemplaza por su cuerpo. El resto de la línea
/// queda intacto.
///
/// Convención simple — sin parámetros posicionales, sin recursión
/// (un alias se expande una vez, no se persigue el resultado).
pub fn expand_aliases<'a>(&self, line: &'a str) -> std::borrow::Cow<'a, str> {
let trimmed = line.trim_start();
let leading_ws = line.len() - trimmed.len();
let (head, rest) = match trimmed.find(char::is_whitespace) {
Some(i) => (&trimmed[..i], &trimmed[i..]),
None => (trimmed, ""),
};
if let Some(body) = self.aliases.get(head) {
let mut out = String::with_capacity(line.len() + body.len());
out.push_str(&line[..leading_ws]);
out.push_str(body);
out.push_str(rest);
std::borrow::Cow::Owned(out)
} else {
std::borrow::Cow::Borrowed(line)
}
}
}
impl From<DedupPolicy> for &'static str {
fn from(p: DedupPolicy) -> Self {
match p {
DedupPolicy::None => "none",
DedupPolicy::IgnoreConsecutive => "ignore_consecutive",
DedupPolicy::EraseDups => "erase_dups",
}
}
}
/// Expande `$VAR` y `${VAR}` en un texto contra `getenv`. Si la variable
/// no existe, se sustituye por cadena vacía — convención bash. Las
/// barras `\$` escapan el signo.
pub fn expand_env(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'$' {
out.push('$');
i += 2;
continue;
}
if c != b'$' {
out.push(bytes[i] as char);
i += 1;
continue;
}
// `$VAR` o `${VAR}`.
let (name_end, with_braces) = if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
// `${VAR}` — buscar la `}` que cierra.
match s[i + 2..].find('}') {
Some(off) => (i + 2 + off, true),
None => {
out.push('$');
i += 1;
continue;
}
}
} else {
let start = i + 1;
let mut end = start;
while end < bytes.len()
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
{
end += 1;
}
if end == start {
// `$` solo: literal.
out.push('$');
i += 1;
continue;
}
(end, false)
};
let name_start = if with_braces { i + 2 } else { i + 1 };
let name = &s[name_start..name_end];
if let Ok(val) = std::env::var(name) {
out.push_str(&val);
}
i = name_end + if with_braces { 1 } else { 0 };
}
out
}
/// Completion declarada por el usuario para un comando concreto.
/// Esquema mínimo en `<cmd>.toml`:
///
/// ```toml
/// flags = ["--foo", "--bar=", "-x"]
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct CommandCompletion {
#[serde(default)]
pub flags: Vec<String>,
}
impl CommandCompletion {
/// Carga `<dir>/<cmd>.toml` si existe, o devuelve `None`. Si el
/// archivo está roto, también `None` — completions son nice-to-have,
/// no deben caer el shell.
pub fn load(dir: &Path, command: &str) -> Option<Self> {
let path = dir.join(format!("{command}.toml"));
let text = std::fs::read_to_string(path).ok()?;
toml::from_str(&text).ok()
}
/// Carga *todas* las completions de un directorio en un HashMap.
/// Útil para precargar al arrancar el shell (un read_dir + N lecturas
/// pequeñas; barato comparado con el coste de un fork).
pub fn load_all(dir: &Path) -> HashMap<String, Self> {
let mut out = HashMap::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for e in entries.flatten() {
let path = e.path();
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
if let Ok(text) = std::fs::read_to_string(&path) {
if let Ok(c) = toml::from_str::<CommandCompletion>(&text) {
out.insert(stem.to_string(), c);
}
}
}
out
}
}
/// Errores al cargar la configuración.
#[derive(Debug)]
pub enum ConfigError {
Io(PathBuf, std::io::Error),
Parse(PathBuf, toml::de::Error),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::Io(p, e) => write!(f, "lectura de {}: {}", p.display(), e),
ConfigError::Parse(p, e) => write!(f, "parseo de {}: {}", p.display(), e),
}
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn missing_file_yields_default() {
let d = tempdir().unwrap();
let c = Config::load(d.path().join("nope.toml")).unwrap();
assert_eq!(c, Config::default());
}
#[test]
fn parses_a_full_example() {
let d = tempdir().unwrap();
let path = d.path().join("shumarc.toml");
std::fs::write(
&path,
r#"
[aliases]
ll = "ls -la"
gs = "git status"
[env]
EDITOR = "hx"
[prompt]
segments = ["cwd", "git", "exit"]
[history]
dedup = "erase_dups"
[capture]
limit_mb = 16
spill = true
"#,
)
.unwrap();
let c = Config::load(&path).unwrap();
assert_eq!(c.aliases.get("ll").map(|s| s.as_str()), Some("ls -la"));
assert_eq!(c.env.get("EDITOR").map(|s| s.as_str()), Some("hx"));
assert_eq!(c.prompt.segments, vec!["cwd", "git", "exit"]);
assert_eq!(c.history.dedup, DedupPolicy::EraseDups);
assert_eq!(c.capture.limit_mb, 16);
assert!(c.capture.spill);
}
#[test]
fn partial_toml_falls_back_to_defaults() {
// Sólo aliases — el resto debe defaultear, no fallar.
let d = tempdir().unwrap();
let path = d.path().join("shumarc.toml");
std::fs::write(&path, "[aliases]\nll = \"ls -la\"\n").unwrap();
let c = Config::load(&path).unwrap();
assert_eq!(c.aliases.len(), 1);
assert_eq!(c.prompt, PromptConfig::default());
assert_eq!(c.capture, CaptureConfig::default());
}
#[test]
fn alias_expansion_replaces_first_word_only() {
let mut c = Config::default();
c.aliases.insert("ll".into(), "ls -la".into());
assert_eq!(c.expand_aliases("ll"), "ls -la");
assert_eq!(c.expand_aliases("ll src/"), "ls -la src/");
// `ll` en el medio no es un alias.
assert_eq!(c.expand_aliases("echo ll"), "echo ll");
}
#[test]
fn alias_preserves_leading_whitespace() {
let mut c = Config::default();
c.aliases.insert("ll".into(), "ls -la".into());
// Un comando indentado mantiene su indentación tras expandir.
assert_eq!(c.expand_aliases(" ll src/"), " ls -la src/");
}
#[test]
fn alias_does_not_recurse() {
// No queremos que un alias expandido se vuelva a expandir —
// evita bucles infinitos triviales (ll=ls, ls=ll).
let mut c = Config::default();
c.aliases.insert("a".into(), "b".into());
c.aliases.insert("b".into(), "c".into());
assert_eq!(c.expand_aliases("a"), "b");
}
#[test]
fn expand_env_substitutes_vars() {
// Usamos una var artificial para no colisionar con el entorno real.
// SAFETY: ver `Config::apply_env`; en tests de un solo hilo es OK.
std::env::set_var("SHUMA_TEST_VAR", "valor");
assert_eq!(expand_env("$SHUMA_TEST_VAR"), "valor");
assert_eq!(expand_env("${SHUMA_TEST_VAR}/bin"), "valor/bin");
// Variable inexistente → cadena vacía.
std::env::remove_var("SHUMA_TEST_NOPE");
assert_eq!(expand_env("x=$SHUMA_TEST_NOPE!"), "x=!");
// `\$` se escapa.
assert_eq!(expand_env("precio \\$5"), "precio $5");
}
#[test]
fn expand_env_keeps_dollar_alone() {
std::env::remove_var("SHUMA_TEST_FOO");
assert_eq!(expand_env("$ "), "$ ");
assert_eq!(expand_env("$"), "$");
}
#[test]
fn completion_loads_per_command_file() {
let d = tempdir().unwrap();
std::fs::write(
d.path().join("mytool.toml"),
"flags = [\"--foo\", \"--bar=\"]\n",
)
.unwrap();
let c = CommandCompletion::load(d.path(), "mytool").unwrap();
assert_eq!(c.flags, vec!["--foo", "--bar="]);
// Comando inexistente → None.
assert!(CommandCompletion::load(d.path(), "nope").is_none());
}
#[test]
fn completion_loads_all_in_dir() {
let d = tempdir().unwrap();
std::fs::write(d.path().join("alfa.toml"), "flags = [\"--a\"]\n").unwrap();
std::fs::write(d.path().join("beta.toml"), "flags = [\"--b\"]\n").unwrap();
std::fs::write(d.path().join("ignored.txt"), "no soy toml").unwrap();
let all = CommandCompletion::load_all(d.path());
assert_eq!(all.len(), 2);
assert!(all.contains_key("alfa"));
assert!(all.contains_key("beta"));
assert!(!all.contains_key("ignored"));
}
#[test]
fn corrupt_completion_file_is_skipped() {
let d = tempdir().unwrap();
std::fs::write(d.path().join("bad.toml"), "not = valid = toml").unwrap();
std::fs::write(d.path().join("good.toml"), "flags = [\"--ok\"]\n").unwrap();
let all = CommandCompletion::load_all(d.path());
assert!(all.contains_key("good"));
assert!(!all.contains_key("bad"));
}
}
@@ -0,0 +1,27 @@
[package]
name = "shuma-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Runtime de shuma: WorkspaceManager sobre arje-incarnate. Estado in-memory, lifecycle, reaping."
[dependencies]
shuma-card = { path = "../shuma-card" }
shuma-discern = { workspace = true }
card-core = { workspace = true }
arje-incarnate = { workspace = true }
nix = { workspace = true }
libc = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
ulid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
@@ -0,0 +1,9 @@
# shuma-core
> Tipos de [shuma](../../README.md): `Session`, `Command`, `Output`.
Núcleo sin transporte ni UI. `Session` lleva history, env, cwd. `Command` representa lo tipeado. `Output` puede ser texto, imagen, link.
## Deps
- `serde`, `uuid`
@@ -0,0 +1,9 @@
# shuma-core
> Types of [shuma](../../README.md): `Session`, `Command`, `Output`.
Core without transport or UI. `Session` holds history, env, cwd. `Command` represents what the user typed. `Output` can be text, image, link.
## Deps
- `serde`, `uuid`
@@ -0,0 +1,478 @@
//! Flow channels: data plane sobre Unix socket por edge enriquecido.
//!
//! Cuando un splitter detecta el TypeRef de un edge, además de replicar a
//! los consumers internos del pipeline, se levanta un FlowChannel que
//! expone los bytes a subscribers externos (otros módulos del fractal).
//!
//! ## Diseño
//!
//! - `tokio::sync::broadcast::channel` para fan-out lock-less entre el
//! splitter (sender) y los N subscribers conectados.
//! - `UnixListener` accept-loop: por cada cliente nuevo, spawn una task
//! que drena el receiver y escribe al socket.
//! - Subscribers lentos pueden perder mensajes (broadcast::Receiver::Lagged)
//! — se loguea warn y se sigue. Esto es deliberado para no bloquear el
//! splitter en consumers lentos.
//!
//! ## Lifetime
//!
//! `FlowChannel` se construye con `new(path)`. Cuando se drop:
//! - El `accept_task` se cancela (vía drop del `tokio::task::JoinHandle`
//! que tenemos abort-on-drop).
//! - El socket file se borra del FS (`Drop` impl).
//!
//! Sender clones son baratos; los subscribers conectados se enteran del
//! cierre cuando todos los senders se dropean.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::io::AsyncWriteExt;
use tokio::net::UnixListener;
use tokio::sync::broadcast;
use tokio::task::AbortHandle;
use tracing::{debug, warn};
/// Capacidad del broadcast channel. Si un subscriber está más de N chunks
/// atrasado, queda `Lagged` y empieza a perder mensajes.
const BROADCAST_CAP: usize = 64;
/// Chunks default del replay buffer. Cuando un cliente nuevo se conecta,
/// recibe hasta estos N chunks antes de iniciar el broadcast live.
/// Override via `FlowChannel::with_replay_cap`.
pub const DEFAULT_REPLAY_CHUNKS: usize = 32;
pub struct FlowChannel {
sender: broadcast::Sender<Arc<Vec<u8>>>,
replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
replay_caps: ReplayCaps,
socket_path: PathBuf,
meter: Arc<FlowMeter>,
_accept_handle: AbortOnDrop,
}
/// Contador de bytes y rate (bytes/s ventana 1s).
#[derive(Debug)]
pub struct FlowMeter {
/// Bytes acumulados desde la creación del FlowChannel.
total_bytes: std::sync::atomic::AtomicU64,
/// Ring buffer de (timestamp_ms, bytes_acumulados) para calcular
/// el rate sobre los últimos N samples.
rate_window: Mutex<VecDeque<(u64, u64)>>,
}
const RATE_WINDOW_SAMPLES: usize = 32;
impl FlowMeter {
fn new() -> Self {
Self {
total_bytes: std::sync::atomic::AtomicU64::new(0),
rate_window: Mutex::new(VecDeque::with_capacity(RATE_WINDOW_SAMPLES)),
}
}
fn record(&self, delta: u64) {
let now = self.total_bytes
.fetch_add(delta, std::sync::atomic::Ordering::Relaxed)
+ delta;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
if let Ok(mut w) = self.rate_window.lock() {
if w.len() >= RATE_WINDOW_SAMPLES {
w.pop_front();
}
w.push_back((ts, now));
}
}
/// Bytes totales acumulados desde la creación.
pub fn total_bytes(&self) -> u64 {
self.total_bytes.load(std::sync::atomic::Ordering::Relaxed)
}
/// Bytes por segundo (rolling sobre la ventana). 0 si no hay
/// historia suficiente o si el último sample es muy viejo (>5s).
pub fn bytes_per_sec(&self) -> f64 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let w = match self.rate_window.lock() {
Ok(w) => w,
Err(_) => return 0.0,
};
if w.len() < 2 {
return 0.0;
}
let last = w.back().copied().unwrap();
// Si el último sample tiene >5s, asumimos idle.
if now_ms.saturating_sub(last.0) > 5000 {
return 0.0;
}
let first = w.front().copied().unwrap();
let dt_ms = last.0.saturating_sub(first.0).max(1);
let d_bytes = last.1.saturating_sub(first.1);
(d_bytes as f64 * 1000.0) / dt_ms as f64
}
}
#[derive(Debug, Clone, Copy)]
pub struct ReplayCaps {
/// Máximo de chunks retenidos.
pub chunks: usize,
/// Máximo de bytes (sumando len de chunks). `0` = sin tope.
pub bytes: usize,
}
impl ReplayCaps {
pub fn chunks_only(chunks: usize) -> Self {
Self { chunks: chunks.max(1), bytes: 0 }
}
pub fn new(chunks: usize, bytes: usize) -> Self {
Self { chunks: chunks.max(1), bytes }
}
}
#[derive(Clone)]
pub struct FlowSender {
sender: broadcast::Sender<Arc<Vec<u8>>>,
replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>>,
replay_caps: ReplayCaps,
meter: Arc<FlowMeter>,
}
impl FlowSender {
/// Pushea al broadcast y al replay buffer. Si no hay subscribers,
/// el broadcast::send retorna Err pero igual guardamos en replay
/// (subscribers tarde verán los chunks pasados).
pub fn send(&self, data: Arc<Vec<u8>>) {
let incoming = data.len();
let caps = self.replay_caps;
if let Ok(mut g) = self.replay.lock() {
evict_for_incoming(&mut g, caps, incoming);
g.push_back(data.clone());
}
self.meter.record(incoming as u64);
let _ = self.sender.send(data);
}
}
/// Evict los chunks más viejos para hacer espacio a un chunk entrante de
/// `incoming` bytes — el buffer post-push queda dentro de los caps.
fn evict_for_incoming(buf: &mut VecDeque<Arc<Vec<u8>>>, caps: ReplayCaps, incoming: usize) {
// 1) chunks: dejar lugar para 1 más.
while buf.len() + 1 > caps.chunks {
if buf.pop_front().is_none() {
break;
}
}
// 2) bytes (si está activado).
if caps.bytes > 0 {
let mut current: usize = buf.iter().map(|a| a.len()).sum();
while current + incoming > caps.bytes {
match buf.pop_front() {
Some(c) => current = current.saturating_sub(c.len()),
None => break,
}
}
}
}
impl std::fmt::Debug for FlowChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FlowChannel")
.field("socket_path", &self.socket_path)
.field("subscribers", &self.sender.receiver_count())
.finish()
}
}
impl FlowChannel {
/// Crea un FlowChannel atado al path `socket_path`. Si el path ya
/// existe, lo borra antes de bind (asume restart limpio).
pub fn new(socket_path: PathBuf) -> std::io::Result<Self> {
Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(DEFAULT_REPLAY_CHUNKS))
}
pub fn with_replay_cap(socket_path: PathBuf, chunks: usize) -> std::io::Result<Self> {
Self::with_replay_caps(socket_path, ReplayCaps::chunks_only(chunks))
}
pub fn with_replay_caps(socket_path: PathBuf, caps: ReplayCaps) -> std::io::Result<Self> {
if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path);
}
if let Some(parent) = socket_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let listener = UnixListener::bind(&socket_path)?;
let (tx, _rx_unused) = broadcast::channel::<Arc<Vec<u8>>>(BROADCAST_CAP);
let replay: Arc<Mutex<VecDeque<Arc<Vec<u8>>>>> =
Arc::new(Mutex::new(VecDeque::with_capacity(caps.chunks)));
let tx_for_accept = tx.clone();
let replay_for_accept = replay.clone();
let path_for_log = socket_path.clone();
let join = tokio::spawn(async move {
debug!(path = %path_for_log.display(), "flow channel listening");
loop {
let (mut stream, _addr) = match listener.accept().await {
Ok(p) => p,
Err(e) => {
warn!(?e, "flow channel accept failed");
return;
}
};
// Snapshot del replay buffer Y subscribe al broadcast.
// El orden es crítico: subscribe ANTES de drenar el replay
// para no perder chunks que llegan justo en el medio.
let mut rx = tx_for_accept.subscribe();
let snapshot: Vec<Arc<Vec<u8>>> = {
let g = replay_for_accept.lock().expect("replay lock");
g.iter().cloned().collect()
};
tokio::spawn(async move {
// Fase 1: drenar replay snapshot al subscriber.
for chunk in &snapshot {
if let Err(e) = stream.write_all(chunk).await {
debug!(?e, "flow subscriber dropped during replay");
return;
}
}
// Fase 2: live broadcast.
loop {
match rx.recv().await {
Ok(chunk) => {
if let Err(e) = stream.write_all(&chunk).await {
debug!(?e, "flow subscriber dropped");
return;
}
}
Err(broadcast::error::RecvError::Closed) => return,
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "flow subscriber lagged");
}
}
}
});
}
});
Ok(Self {
sender: tx,
replay,
replay_caps: caps,
socket_path,
meter: Arc::new(FlowMeter::new()),
_accept_handle: AbortOnDrop(join.abort_handle()),
})
}
pub fn meter(&self) -> &FlowMeter {
&self.meter
}
/// Push un chunk al channel. Si no hay subscribers, drop silencioso.
/// Siempre se guarda en el replay buffer (con cap rotation por chunks
/// y opcionalmente por bytes).
pub fn send(&self, data: Vec<u8>) {
let incoming = data.len();
let arc = Arc::new(data);
let caps = self.replay_caps;
if let Ok(mut g) = self.replay.lock() {
evict_for_incoming(&mut g, caps, incoming);
g.push_back(arc.clone());
}
self.meter.record(incoming as u64);
let _ = self.sender.send(arc);
}
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
/// Handle clone-able para que tasks externas (splitter) pushen al
/// channel sin tener ownership del FlowChannel. Cada push se guarda
/// también en el replay buffer y se contabiliza en el meter.
pub fn sender_handle(&self) -> FlowSender {
FlowSender {
sender: self.sender.clone(),
replay: self.replay.clone(),
replay_caps: self.replay_caps,
meter: self.meter.clone(),
}
}
pub fn subscriber_count(&self) -> usize {
self.sender.receiver_count()
}
}
impl Drop for FlowChannel {
fn drop(&mut self) {
// El AbortOnDrop cancela el accept loop; sólo nos queda limpiar el
// socket file.
let _ = std::fs::remove_file(&self.socket_path);
}
}
struct AbortOnDrop(AbortHandle);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
/// Path canónico para un flow channel: `$XDG_RUNTIME_DIR/shuma-flow-<id>.sock`.
pub fn default_flow_socket_path(id: &str) -> PathBuf {
let base = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
let uid = nix::unistd::getuid().as_raw();
let p = format!("/run/user/{uid}");
if std::path::Path::new(&p).exists() {
p
} else {
"/tmp".into()
}
});
PathBuf::from(base).join(format!("shuma-flow-{id}.sock"))
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncReadExt;
use tokio::net::UnixStream;
#[tokio::test]
async fn channel_delivers_to_subscriber() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("flow.sock");
let ch = FlowChannel::new(path.clone()).unwrap();
// Subscriber se conecta.
let path_clone = path.clone();
let task = tokio::spawn(async move {
let mut stream = UnixStream::connect(&path_clone).await.unwrap();
let mut buf = vec![0u8; 64];
let n = stream.read(&mut buf).await.unwrap();
buf.truncate(n);
buf
});
// Damos tiempo al accept.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Hasta que haya 1 receiver_count, el send no llega.
for _ in 0..50 {
if ch.subscriber_count() >= 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
ch.send(b"hello-flow".to_vec());
let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
.await
.expect("timeout")
.unwrap();
assert_eq!(received, b"hello-flow");
}
#[tokio::test]
async fn replay_buffer_serves_late_subscriber() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("flow.sock");
let ch = FlowChannel::new(path.clone()).unwrap();
// Pushes ANTES de cualquier subscriber: van solo al replay.
ch.send(b"chunk-1".to_vec());
ch.send(b"chunk-2".to_vec());
ch.send(b"chunk-3".to_vec());
// Subscriber LATE — debe recibir los 3 chunks del replay.
let path_clone = path.clone();
let task = tokio::spawn(async move {
let mut stream = UnixStream::connect(&path_clone).await.unwrap();
let mut buf = vec![0u8; 256];
// Leemos hasta recibir los 3 chunks (21 bytes esperados).
let mut total = Vec::new();
for _ in 0..20 {
let n = stream.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
total.extend_from_slice(&buf[..n]);
if total.len() >= 21 {
break;
}
}
total
});
let received = tokio::time::timeout(std::time::Duration::from_secs(2), task)
.await
.expect("timeout")
.unwrap();
let s = String::from_utf8_lossy(&received);
assert!(s.contains("chunk-1"), "got: {s:?}");
assert!(s.contains("chunk-2"), "got: {s:?}");
assert!(s.contains("chunk-3"), "got: {s:?}");
}
#[tokio::test]
async fn replay_evicts_by_bytes_cap() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("flow.sock");
// chunks=100 (no limita), bytes=20: deberíamos retener sólo los
// últimos chunks cuyos bytes sumen ≤ 20.
let ch = FlowChannel::with_replay_caps(path.clone(), ReplayCaps::new(100, 20)).unwrap();
ch.send(b"AAAAAAAA".to_vec()); // 8 bytes
ch.send(b"BBBBBBBB".to_vec()); // 8 → total 16
ch.send(b"CCCCCCCC".to_vec()); // 8 → total 24 > 20, evict A → 16
ch.send(b"DDDDDDDD".to_vec()); // 8 → total 24 > 20, evict B → 16
let path_clone = path.clone();
let task = tokio::spawn(async move {
let mut stream = UnixStream::connect(&path_clone).await.unwrap();
let mut buf = vec![0u8; 64];
let mut total = Vec::new();
for _ in 0..20 {
let n = stream.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
total.extend_from_slice(&buf[..n]);
if total.len() >= 16 {
break;
}
}
total
});
let got = tokio::time::timeout(std::time::Duration::from_secs(2), task)
.await
.expect("timeout")
.unwrap();
let s = String::from_utf8_lossy(&got);
// Sólo C y D (los más viejos A y B fueron evicted).
assert!(!s.contains("AAAA"), "should have evicted A: {s:?}");
assert!(!s.contains("BBBB"), "should have evicted B: {s:?}");
assert!(s.contains("CCCC"), "should keep C: {s:?}");
assert!(s.contains("DDDD"), "should keep D: {s:?}");
}
#[tokio::test]
async fn drop_removes_socket() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("flow.sock");
{
let _ch = FlowChannel::new(path.clone()).unwrap();
assert!(path.exists());
}
// Después del drop, el socket file no debe quedar.
// Damos un pelín de tiempo al runtime para que el drop corra
// mientras estamos en task.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
assert!(!path.exists());
}
}
@@ -0,0 +1,261 @@
//! `shuma-core` — runtime in-memory de Workspaces y comandos.
//!
//! Mantiene un estado tokio-friendly (Mutex sobre HashMap) con:
//! - Workspaces vivos (id → state).
//! - PIDs de comandos lanzados, indexados por workspace.
//! - Reaping cooperativo: `reap_dead()` cosecha hijos terminados.
// `pipeline` necesita `unsafe` puntual para `libc::close` y construir
// `OwnedFd` desde fds que armamos con `pipe2(2)`. El resto del crate
// permanece safe — el cargo lint `unsafe_code` queda permitido sólo en
// el módulo concreto.
#![deny(unsafe_op_in_unsafe_fn)]
pub mod flow_channel;
pub mod logbuf;
pub mod persist;
pub mod pipeline;
pub mod stats;
use card_core::{Card, Payload, Supervision};
use arje_incarnate::{Incarnator, IncarnatorConfig};
use nix::sys::signal::{kill, Signal};
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
use nix::unistd::Pid;
use shuma_card::{CommandRef, PipelineSpec, WorkspaceId, WorkspaceSpec};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use thiserror::Error;
use tokio::sync::Mutex;
use tracing::{info, warn};
use ulid::Ulid;
#[derive(Debug, Error)]
pub enum CoreError {
#[error("workspace {0} not found")]
WorkspaceNotFound(WorkspaceId),
#[error("compile: {0}")]
Compile(#[from] shuma_card::CompileError),
#[error("incarnate: {0}")]
Incarnate(#[from] arje_incarnate::IncarnateError),
}
#[derive(Debug)]
pub struct WorkspaceState {
pub id: WorkspaceId,
pub spec: WorkspaceSpec,
pub root_card: Card,
pub commands: HashMap<Ulid, CommandState>,
pub started: Instant,
/// Última muestra de `(wall_instant, cpu_usec)` usada para calcular
/// `cpu_percent` en la próxima medición. None hasta el primer measure.
pub last_cpu_sample: Option<(Instant, u64)>,
/// Ring buffer de samples recientes para sparklines. Se popula cada
/// vez que `workspace_stats` se llama (típicamente desde el shell).
/// Cap 64 samples = ~2 minutos a 2s/sample.
pub stats_history: std::collections::VecDeque<stats::WorkspaceStats>,
}
const STATS_HISTORY_CAP: usize = 64;
#[derive(Debug, Clone)]
pub struct CommandState {
pub id: Ulid,
pub label: String,
pub pid: Pid,
pub alive: bool,
pub exit_status: Option<i32>,
/// Ring buffer del stdout. `None` para comandos sin captura.
pub stdout: Option<logbuf::LogBuf>,
/// Ring buffer del stderr. Separado de `stdout` para que el CLI
/// pueda filtrarlos. `None` para comandos sin captura.
pub stderr: Option<logbuf::LogBuf>,
/// Si el comando fue lanzado como parte de un Pipeline, su ULID.
pub pipeline_id: Option<Ulid>,
}
/// Stream a leer en `get_command_logs`. `Both` concatena stderr-después-stdout
/// para una vista combinada (orden temporal aproximado).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogStream {
Stdout,
Stderr,
Both,
}
pub struct WorkspaceManager {
inner: Arc<Mutex<Inner>>,
incarnator: Arc<Incarnator>,
/// True si hubo alguna mutación desde el último `save_snapshot`.
/// `save_snapshot` skip si false (snapshot incremental — evita
/// re-serialize cuando nada cambió, ej. SIGTERM tras un período idle).
dirty: std::sync::atomic::AtomicBool,
}
struct Inner {
workspaces: HashMap<WorkspaceId, WorkspaceState>,
/// Definiciones nombradas de pipelines persistidas. NO es lo mismo
/// que "pipelines vivos" — son specs guardados para reusar con
/// `run-saved`. Sobreviven restart vía snapshot.
saved_pipelines: HashMap<String, PipelineSpec>,
/// Flow channels vivos por pipeline. Se retienen hasta que el
/// pipeline termine — cuando todos los hijos del pipeline murieron,
/// el reaper los borra (futuro). v1: viven hasta `stop_pipeline_flows`
/// explícito o hasta shutdown.
pipeline_flows: HashMap<Ulid, Vec<crate::flow_channel::FlowChannel>>,
/// Specs de comandos `run()` con `restart_on_failure=true`. Indexed
/// por command_id. Cuando `reap_dead` detecta exit!=0, se relauncha
/// con la misma spec (nuevo pid y nuevo command_id se asigna por
/// el nuevo state pero el restart_spec sigue ligado al original).
restart_specs: HashMap<Ulid, RestartSpec>,
/// Supervisores de pipelines con `restart_on_failure`. Indexed por
/// pipeline_id. Cuando `reap_dead` detecta que el pipeline tuvo
/// algún command failure, agrega un entry a `pending_pipeline_restarts`.
pipeline_supervisors: HashMap<Ulid, PipelineSupervisor>,
/// Cola de pipelines pendientes de restart. El daemon la drena en
/// cada loop del reaper, hace stop + run_pipeline.
pending_pipeline_restarts: Vec<Ulid>,
}
#[derive(Debug, Clone)]
pub struct PipelineSupervisor {
pub workspace: WorkspaceId,
pub spec: PipelineSpec,
pub tap: bool,
pub restart_count: u32,
/// Backoff actual (ms) — escala exponencialmente con cada restart.
pub current_backoff_ms: u64,
}
#[derive(Debug, Clone)]
struct RestartSpec {
workspace: WorkspaceId,
exec: String,
argv: Vec<String>,
envp: Vec<(String, String)>,
/// Backoff inicial (ms). Crece exponencialmente hasta max_backoff_ms.
backoff_ms: u64,
max_backoff_ms: u64,
/// Cantidad de restarts ya ejecutados (para tracking).
restart_count: u32,
}
#[derive(Debug, Clone)]
pub struct CommandSummary {
pub id: Ulid,
pub label: String,
pub pid: i32,
}
#[derive(Debug, Clone, Default)]
pub struct HealthCounts {
pub alive_workspaces: u32,
pub alive_commands: u32,
pub alive_pipelines: u32,
pub active_flows: u32,
}
#[derive(Debug, Clone)]
pub struct CommandInfo {
pub id: Ulid,
pub label: String,
pub pid: i32,
pub alive: bool,
pub exit_status: Option<i32>,
pub log_bytes: u64,
}
/// Lee VmRSS (bytes) de `/proc/<pid>/status`. Helper local para
/// reap_dead que no necesita el full stats. Devuelve 0 si el proc no
/// existe o el campo no aparece.
fn read_proc_rss(pid: i32) -> Option<u64> {
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
status
.lines()
.find_map(|l| l.strip_prefix("VmRSS:").map(str::trim))
.and_then(|s| s.split_whitespace().next())
.and_then(|s| s.parse::<u64>().ok())
.map(|kb| kb * 1024)
}
fn spawn_log_drainer(read_fd: std::os::fd::RawFd, logs: logbuf::LogBuf) {
// Marcar non-blocking + envolver en AsyncFd; igual patrón que el tap.
// SAFETY: F_SETFL sobre fd válido.
unsafe {
let flags = libc::fcntl(read_fd, libc::F_GETFL, 0);
if flags >= 0 {
libc::fcntl(read_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}
tokio::spawn(async move {
// SAFETY: ownership del fd transferido al drainer task.
let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(read_fd) };
let afd = match tokio::io::unix::AsyncFd::with_interest(owned, tokio::io::Interest::READABLE) {
Ok(a) => a,
Err(e) => {
tracing::warn!(?e, "log drainer AsyncFd failed");
return;
}
};
let mut buf = [0u8; 4096];
loop {
let mut guard = match afd.readable().await {
Ok(g) => g,
Err(_) => break,
};
use std::os::fd::AsRawFd;
let fd = afd.as_raw_fd();
// SAFETY: read sobre fd válido.
let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
if r > 0 {
logs.append(&buf[..r as usize]);
continue;
}
if r == 0 {
break; // EOF
}
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
guard.clear_ready();
continue;
}
tracing::warn!(?err, "log drainer read err");
break;
}
});
}
trait OwnedFdFromRawCompat: Sized {
unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self;
}
impl OwnedFdFromRawCompat for std::os::fd::OwnedFd {
unsafe fn from_raw_fd_compat(fd: std::os::fd::RawFd) -> Self {
use std::os::fd::FromRawFd;
// SAFETY: el caller transfiere ownership de fd a OwnedFd.
unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }
}
}
#[derive(Debug, Clone)]
pub struct WorkspaceSnapshot {
pub id: WorkspaceId,
pub label: String,
pub commands: u32,
pub uptime_ms: u64,
}
fn short_ulid(u: &Ulid) -> String {
let s = u.to_string();
s[s.len() - 6..].to_string()
}
// `impl WorkspaceManager` partido por dominio (regla dura #1, 1517 LOC):
mod pipelines;
mod runtime;
mod workspaces;
#[cfg(test)]
mod tests;
@@ -0,0 +1,122 @@
//! Ring buffer en memoria para capturar stdout/stderr de comandos.
//!
//! Tamaño fijo por comando (config: `MAX_LOG_BYTES`). Cuando se llena,
//! descarta los bytes más viejos. Pensado para diagnostico rápido, no
//! para retención histórica — eso es trabajo de un journald-like aparte.
use std::sync::{Arc, Mutex};
/// Bytes máximos retenidos por comando. 64 KiB cubre logs típicos sin
/// abusar de memoria si el daemon tiene cientos de comandos vivos.
pub const MAX_LOG_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone)]
pub struct LogBuf {
inner: Arc<Mutex<Inner>>,
}
#[derive(Debug)]
struct Inner {
/// Bytes raw. Cuando se acerca al cap, descartamos head para mantener
/// el tail.
buf: Vec<u8>,
cap: usize,
/// Total escrito alguna vez (no decrementado al recortar).
written_total: u64,
}
impl LogBuf {
pub fn new() -> Self {
Self::with_cap(MAX_LOG_BYTES)
}
pub fn with_cap(cap: usize) -> Self {
Self {
inner: Arc::new(Mutex::new(Inner {
buf: Vec::with_capacity(cap.min(4096)),
cap,
written_total: 0,
})),
}
}
pub fn append(&self, data: &[u8]) {
let Ok(mut g) = self.inner.lock() else { return };
g.written_total += data.len() as u64;
g.buf.extend_from_slice(data);
// Recorte cuando excede cap (con un pequeño slack para evitar
// shift en cada append). El usuario ve sólo el tail.
if g.buf.len() > g.cap + 1024 {
let drop = g.buf.len() - g.cap;
g.buf.drain(..drop);
}
}
/// Devuelve el tail de hasta `n` bytes (o todo si `n=0`).
pub fn tail(&self, n: usize) -> Vec<u8> {
let g = match self.inner.lock() {
Ok(g) => g,
Err(_) => return Vec::new(),
};
if n == 0 || n >= g.buf.len() {
return g.buf.clone();
}
g.buf[g.buf.len() - n..].to_vec()
}
/// Cuántos bytes hay actualmente en el buffer.
pub fn len(&self) -> usize {
self.inner.lock().map(|g| g.buf.len()).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn written_total(&self) -> u64 {
self.inner.lock().map(|g| g.written_total).unwrap_or(0)
}
}
impl Default for LogBuf {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn append_and_tail_basic() {
let lb = LogBuf::with_cap(100);
lb.append(b"hello ");
lb.append(b"world\n");
let t = lb.tail(0);
assert_eq!(t, b"hello world\n");
}
#[test]
fn cap_drops_oldest() {
let lb = LogBuf::with_cap(10);
lb.append(&[b'a'; 8]);
lb.append(&[b'b'; 8]);
// Después del recorte, debe quedar ~10 bytes pero el slack
// permite hasta 10+1024. Como pasamos slack, no se recorta aún
// en este caso (16 bytes < 10+1024). Forzamos un append grande.
lb.append(&[b'c'; 2048]);
assert!(lb.len() <= 10 + 1024);
let t = lb.tail(0);
// El tail debe contener 'c's (los más recientes).
assert!(t.iter().filter(|&&b| b == b'c').count() > 0);
}
#[test]
fn written_total_tracks_all() {
let lb = LogBuf::with_cap(10);
lb.append(b"abcdef");
lb.append(b"ghijkl");
assert_eq!(lb.written_total(), 12);
}
}
@@ -0,0 +1,383 @@
//! Persistencia del estado del WorkspaceManager.
//!
//! v1: sólo `WorkspaceSpec`s vivos. Los comandos (PIDs) NO se persisten —
//! el kernel los mata al cerrar el daemon. Sólo la *intención declarada*
//! (Workspaces creados con su spec) sobrevive a un reboot del daemon.
use crate::WorkspaceManager;
use serde::{Deserialize, Serialize};
use shuma_card::{PipelineSpec, WorkspaceId, WorkspaceSpec};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
/// v2 agregó `saved_pipelines`. v3 agrega `live_pipelines`. v4 agrega
/// `stats_history` por workspace (sparkline survives daemon restart).
/// Versiones inferiores leen campos ausentes como vacío.
pub const SNAPSHOT_VERSION: u16 = 4;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShipoteSnapshot {
pub version: u16,
pub timestamp_ms: u64,
pub workspaces: Vec<WorkspaceEntry>,
#[serde(default)]
pub saved_pipelines: Vec<PipelineEntry>,
/// Pipelines vivos con supervisor (`restart_on_failure=true`) al
/// momento del snapshot. El daemon los relanza al restore.
#[serde(default)]
pub live_pipelines: Vec<LivePipelineEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceEntry {
pub id: WorkspaceId,
pub spec: WorkspaceSpec,
/// Stats history persistida — cap reasonable para no inflar el JSON.
/// Sólo se guardan campos serializables (no Instant).
#[serde(default)]
pub stats_history: Vec<PersistedStats>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedStats {
pub commands_alive: u32,
pub commands_total: u32,
pub rss_bytes: Option<u64>,
pub rss_peak_bytes: Option<u64>,
pub cpu_usec: Option<u64>,
pub cpu_percent: Option<f32>,
pub cpu_cores: u32,
pub uptime_ms: u64,
}
impl From<&crate::stats::WorkspaceStats> for PersistedStats {
fn from(s: &crate::stats::WorkspaceStats) -> Self {
Self {
commands_alive: s.commands_alive,
commands_total: s.commands_total,
rss_bytes: s.rss_bytes,
rss_peak_bytes: s.rss_peak_bytes,
cpu_usec: s.cpu_usec,
cpu_percent: s.cpu_percent,
cpu_cores: s.cpu_cores,
uptime_ms: s.uptime_ms,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineEntry {
pub name: String,
pub spec: PipelineSpec,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LivePipelineEntry {
pub workspace: WorkspaceId,
pub spec: PipelineSpec,
pub tap: bool,
}
impl ShipoteSnapshot {
pub fn write(&self, path: &Path) -> anyhow::Result<()> {
let bytes = serde_json::to_vec_pretty(self)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn read(path: &Path) -> anyhow::Result<Self> {
let bytes = std::fs::read(path)?;
let snap: ShipoteSnapshot = serde_json::from_slice(&bytes)?;
// v1 y v2 son compatibles forward (v1 sin saved_pipelines lee como vec vacío).
if snap.version > SNAPSHOT_VERSION {
anyhow::bail!(
"snapshot version {} no soportada (esperada ≤ {})",
snap.version,
SNAPSHOT_VERSION
);
}
Ok(snap)
}
}
/// Path canónico del snapshot: `$XDG_STATE_HOME/shuma/state.json`,
/// fallback `$HOME/.local/state/shuma/state.json`,
/// fallback `/tmp/shuma-state-$UID.json`.
pub fn default_snapshot_path() -> PathBuf {
if let Ok(state) = std::env::var("XDG_STATE_HOME") {
return PathBuf::from(state).join("shuma/state.json");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".local/state/shuma/state.json");
}
let uid = nix::unistd::getuid().as_raw();
PathBuf::from(format!("/tmp/shuma-state-{uid}.json"))
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
impl WorkspaceManager {
/// Toma snapshot del estado actual.
pub async fn snapshot(&self) -> ShipoteSnapshot {
const PERSIST_STATS_CAP: usize = 16;
let g = self.inner.lock().await;
let workspaces = g
.workspaces
.iter()
.map(|(id, ws)| {
// Persist sólo los últimos N samples — el resto crece
// y el JSON se infla.
let take = ws.stats_history.len().min(PERSIST_STATS_CAP);
let skip = ws.stats_history.len() - take;
let stats_history: Vec<PersistedStats> = ws
.stats_history
.iter()
.skip(skip)
.map(PersistedStats::from)
.collect();
WorkspaceEntry {
id: *id,
spec: ws.spec.clone(),
stats_history,
}
})
.collect();
let saved_pipelines = g
.saved_pipelines
.iter()
.map(|(name, spec)| PipelineEntry {
name: name.clone(),
spec: spec.clone(),
})
.collect();
// Pipelines vivos con supervisor — preserva la intención. Los
// pids/sockets/discernments son ephemeral y se regeneran al
// restore (relaunch desde cero).
let live_pipelines = g
.pipeline_supervisors
.values()
.map(|sup| LivePipelineEntry {
workspace: sup.workspace,
spec: sup.spec.clone(),
tap: sup.tap,
})
.collect();
ShipoteSnapshot {
version: SNAPSHOT_VERSION,
timestamp_ms: now_ms(),
workspaces,
saved_pipelines,
live_pipelines,
}
}
/// Escribe snapshot a disco. Si `is_dirty()` es false **y** el path
/// existe (snapshot previo válido), skip la escritura.
pub async fn save_snapshot(&self, path: &Path) -> anyhow::Result<()> {
if !self.is_dirty() && path.exists() {
info!(path = %path.display(), "snapshot SKIPPED (clean)");
return Ok(());
}
let snap = self.snapshot().await;
snap.write(path)?;
// Clear dirty: lo que está en disco es el current state.
self.dirty
.store(false, std::sync::atomic::Ordering::Relaxed);
info!(path = %path.display(), workspaces = snap.workspaces.len(), "snapshot saved");
Ok(())
}
/// Carga snapshot desde disco y restaura los Workspaces + saved
/// pipelines. Devuelve los `live_pipelines` para que el caller
/// (daemon) los relance — no podemos relanzarlos desde acá porque
/// `run_pipeline` necesita `Incarnator` + `DiscernPipeline`.
/// Errores no-fatales (workspaces inválidos) se loguean y se saltan.
pub async fn restore_snapshot(
self: &std::sync::Arc<Self>,
path: &Path,
) -> anyhow::Result<RestoreOutcome> {
let snap = match ShipoteSnapshot::read(path) {
Ok(s) => s,
Err(e) => {
warn!(?e, path = %path.display(), "no snapshot — start fresh");
return Ok(RestoreOutcome::default());
}
};
let mut out = RestoreOutcome::default();
for entry in snap.workspaces {
// v2+: reusamos el id original así clients que tracking
// workspace_id no se rompen al restart.
let label = entry.spec.label.clone();
let id = entry.id;
let history = entry.stats_history;
match self.create_with_id(id, entry.spec).await {
Ok(_) => {
out.workspaces_restored += 1;
// Hidratar history persistida. Convertimos
// PersistedStats → WorkspaceStats (perdemos
// los campos no serializables como `source`).
if !history.is_empty() {
let mut g = self.inner.lock().await;
if let Some(ws) = g.workspaces.get_mut(&id) {
for ps in history {
ws.stats_history.push_back(crate::stats::WorkspaceStats {
commands_alive: ps.commands_alive,
commands_total: ps.commands_total,
rss_bytes: ps.rss_bytes,
rss_peak_bytes: ps.rss_peak_bytes,
cpu_usec: ps.cpu_usec,
cpu_percent: ps.cpu_percent,
cpu_cores: ps.cpu_cores,
source: "persisted".into(),
uptime_ms: ps.uptime_ms,
});
}
}
}
}
Err(e) => warn!(?e, %label, "skipped workspace en restore"),
}
}
for entry in snap.saved_pipelines {
self.save_pipeline(entry.name, entry.spec).await;
out.saved_pipelines_restored += 1;
}
out.live_pipelines = snap.live_pipelines;
// Restore no cuenta como mutación — lo que está en disco es lo
// que acabamos de cargar. Sin esto, el próximo SIGTERM siempre
// re-escribiría aunque no hubiese cambios reales.
self.dirty
.store(false, std::sync::atomic::Ordering::Relaxed);
info!(
workspaces = out.workspaces_restored,
saved_pipelines = out.saved_pipelines_restored,
live_pipelines = out.live_pipelines.len(),
"snapshot restored"
);
Ok(out)
}
}
/// Lo que el caller del restore obtiene. Las `live_pipelines` requieren
/// `Incarnator + DiscernPipeline` para relanzarlas → el caller las
/// procesa (típicamente el daemon).
#[derive(Debug, Default)]
pub struct RestoreOutcome {
pub workspaces_restored: usize,
pub saved_pipelines_restored: usize,
pub live_pipelines: Vec<LivePipelineEntry>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::WorkspaceManager;
use arje_incarnate::IncarnatorConfig;
use shuma_card::{ExitPolicy, WorkspaceSpec};
use std::sync::Arc;
fn sample_ws(label: &str) -> WorkspaceSpec {
WorkspaceSpec {
label: label.into(),
soma: Default::default(),
permissions: Default::default(),
ttl: None,
flow_dirs: vec![],
on_exit: ExitPolicy::Reap,
quota_enforce: Default::default(),
}
}
#[tokio::test]
async fn roundtrip_snapshot_preserves_ulids() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state.json");
let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let (id1, _) = mgr1.create(sample_ws("a")).await.unwrap();
let (id2, _) = mgr1.create(sample_ws("b")).await.unwrap();
mgr1.save_snapshot(&path).await.unwrap();
let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let out = mgr2.restore_snapshot(&path).await.unwrap();
assert_eq!(out.workspaces_restored, 2);
let listed = mgr2.list().await;
let restored_ids: std::collections::HashSet<_> = listed.iter().map(|s| s.id).collect();
assert!(restored_ids.contains(&id1));
assert!(restored_ids.contains(&id2));
}
#[tokio::test]
async fn save_snapshot_skips_when_clean() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state.json");
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let _ = mgr.create(sample_ws("dirty-test")).await.unwrap();
assert!(mgr.is_dirty(), "create debería marcar dirty");
mgr.save_snapshot(&path).await.unwrap();
assert!(!mgr.is_dirty(), "save_snapshot debería limpiar dirty");
let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap();
// Esperamos un pelín para que mtime cambie si fuera re-escrito.
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
// Segundo save sin mutación → skip.
mgr.save_snapshot(&path).await.unwrap();
let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap();
assert_eq!(mtime1, mtime2, "skip cuando clean — mtime no cambia");
}
#[tokio::test]
async fn snapshot_includes_saved_pipelines() {
use shuma_card::{CommandRef, DiscernPolicy, PipelineSpec};
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state.json");
let mgr1 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let (ws_id, _) = mgr1.create(sample_ws("ws")).await.unwrap();
let spec = PipelineSpec {
label: "echo-cat".into(),
workspace: ws_id,
nodes: vec![CommandRef {
label: "n1".into(),
payload: card_core::Payload::Native {
exec: "/bin/echo".into(),
argv: vec!["hi".into()],
envp: vec![],
},
soma: Default::default(),
flows: Default::default(),
supervision: card_core::Supervision::OneShot,
}],
edges: vec![],
discern: DiscernPolicy::default(),
restart_on_failure: false,
restart_backoff_ms: 200,
restart_max_backoff_ms: 30_000,
restart_max: 0,
};
mgr1.save_pipeline("daily".into(), spec).await;
mgr1.save_snapshot(&path).await.unwrap();
let mgr2 = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
mgr2.restore_snapshot(&path).await.unwrap();
let saved = mgr2.list_saved_pipelines().await;
assert_eq!(saved, vec!["daily".to_string()]);
let got = mgr2.get_saved_pipeline("daily").await.expect("saved");
assert_eq!(got.label, "echo-cat");
}
#[test]
fn default_path_ends_with_state_json() {
let p = default_snapshot_path();
assert!(p.to_string_lossy().ends_with("state.json"));
}
}
@@ -0,0 +1,808 @@
//! Pipeline runtime: encadena nodos con pipes y opcionalmente intercepta
//! cada flow para discernir su contenido.
//!
//! Cada nodo se encarna via [`arje_incarnate::Incarnator`] — eso significa
//! que **cada comando puede tener su propio SomaSpec** (namespaces, cgroup,
//! rlimits) heredado del workspace. La conexión stdin↔stdout se hace con
//! `pipe2(2)` + `ChildStdio` declarativo: el callback de clone(2) hace los
//! `dup2` pre-execve sin romper la regla async-signal-safe.
use crate::CoreError;
use card_core::Payload;
use arje_incarnate::{ChildStdio, Incarnator};
use nix::fcntl::OFlag;
use nix::unistd::pipe2;
use shuma_card::PipelineSpec;
use shuma_discern::{DiscernPipeline, Discernment, Hint};
use std::os::fd::{AsRawFd, IntoRawFd, RawFd};
use std::sync::Arc;
use tokio::io::unix::AsyncFd;
use tokio::io::Interest;
use tracing::{debug, info, warn};
use ulid::Ulid;
/// Resultado de lanzar un pipeline.
#[derive(Debug)]
pub struct PipelineLaunch {
pub pipeline: Ulid,
pub command_pids: Vec<(String, i32)>,
/// Discernments por edge, en el mismo orden que `spec.edges`.
pub edge_discernments: Vec<EdgeDiscernment>,
}
#[derive(Debug, Clone)]
pub struct EdgeDiscernment {
pub from_label: String,
pub from_output: String,
pub to_label: String,
pub to_input: String,
pub discernment: Option<Discernment>,
/// Path del Unix socket donde otros módulos pueden suscribirse al
/// stream replicado por este edge. `None` cuando tap=false (no hay
/// data plane porque no hay sampling).
pub flow_socket: Option<std::path::PathBuf>,
}
/// Lanza un pipeline conectando nodos por stdin/stdout. Cada nodo se
/// encarna via `Incarnator` (con o sin namespacing según su SomaSpec).
///
/// Soporta:
/// - Pipeline lineal (1 producer → 1 consumer).
/// - **Fan-out** (1 producer → N consumers): shuma interpone un
/// splitter que duplica bytes a cada destino. Cuando `tap=true`, el
/// splitter además samplea para discernir.
/// - Múltiples predecessors por nodo NO se soporta aún (fan-in): sólo se
/// honra el primer edge entrante.
pub async fn run_pipeline(
spec: &PipelineSpec,
workspace_label: &str,
tap: bool,
discerner: Arc<DiscernPipeline>,
incarnator: Arc<Incarnator>,
manager: Option<Arc<crate::WorkspaceManager>>,
) -> Result<PipelineLaunch, CoreError> {
spec.validate()?;
let n = spec.nodes.len();
info!(
nodes = n,
edges = spec.edges.len(),
tap,
"launching pipeline (incarnated)"
);
// Pre-compute grafo:
// - `consumers[i]` = índices de edges salientes de `i`.
// - `predecessors[j]` = índices de edges entrantes a `j`.
let mut consumers: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut predecessors: Vec<Vec<usize>> = vec![Vec::new(); n];
for (idx, e) in spec.edges.iter().enumerate() {
consumers[e.from].push(idx);
predecessors[e.to].push(idx);
}
// Por cada edge: par (r_to_consumer, w_from_producer_side).
// El consumer recibe r_to_consumer; el producer escribe a w_from_producer_side
// (directa o vía splitter).
let mut edge_r: Vec<RawFd> = vec![-1; spec.edges.len()];
let mut edge_w: Vec<RawFd> = vec![-1; spec.edges.len()];
for i in 0..spec.edges.len() {
let (r, w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
})?;
edge_r[i] = r.into_raw_fd();
edge_w[i] = w.into_raw_fd();
}
let mut consumer_stdin_fd: Vec<Option<RawFd>> = vec![None; n];
let mut producer_stdout_fd: Vec<Option<RawFd>> = vec![None; n];
let mut splitter_specs: Vec<SplitterSpec> = Vec::new();
let mut merger_specs: Vec<MergerSpec> = Vec::new();
// Stdout del producer: directo a edge_w[único] si tiene 1 consumer y NO tap;
// sino, pipe propio que va al splitter task.
for i in 0..n {
if consumers[i].is_empty() {
continue;
}
if consumers[i].len() == 1 && !tap {
producer_stdout_fd[i] = Some(edge_w[consumers[i][0]]);
continue;
}
// Splitter: pipe propio para el productor → splitter lee y replica a edge_w[*].
let (prod_r, prod_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
})?;
producer_stdout_fd[i] = Some(prod_w.into_raw_fd());
let prod_r_fd = prod_r.into_raw_fd();
let mut consumer_writes: Vec<RawFd> = Vec::with_capacity(consumers[i].len());
let mut edge_meta: Vec<EdgeMeta> = Vec::with_capacity(consumers[i].len());
for edge_idx in &consumers[i] {
let edge = &spec.edges[*edge_idx];
consumer_writes.push(edge_w[*edge_idx]);
edge_meta.push(EdgeMeta {
from_label: spec.nodes[edge.from].label.clone(),
from_output: edge.from_output.clone(),
to_label: spec.nodes[edge.to].label.clone(),
to_input: edge.to_input.clone(),
});
}
splitter_specs.push(SplitterSpec {
producer_r_fd: prod_r_fd,
consumer_w_fds: consumer_writes,
edges: edge_meta,
tap,
sample_bytes: spec.discern.sample_bytes,
max_bytes_per_sec: spec.discern.max_bytes_per_sec,
});
}
// Stdin del consumer: edge_r[único] si tiene 1 predecessor; sino, merger.
for j in 0..n {
match predecessors[j].len() {
0 => {}
1 => {
consumer_stdin_fd[j] = Some(edge_r[predecessors[j][0]]);
}
_ => {
// Merger: lee de N edge_r y escribe a un nuevo pipe cuyo
// read end es el stdin del consumer.
let (cons_r, cons_w) = pipe2(OFlag::O_CLOEXEC).map_err(|e| {
CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
})?;
consumer_stdin_fd[j] = Some(cons_r.into_raw_fd());
let inputs: Vec<RawFd> = predecessors[j]
.iter()
.map(|eidx| edge_r[*eidx])
.collect();
merger_specs.push(MergerSpec {
producer_r_fds: inputs,
consumer_w_fd: cons_w.into_raw_fd(),
});
}
}
}
// Encarnamos cada nodo con su stdin/stdout fd asignado.
let mut pids = Vec::with_capacity(n);
for (i, node) in spec.nodes.iter().enumerate() {
match &node.payload {
Payload::Native { .. } | Payload::Legacy { .. } => {}
_ => {
return Err(CoreError::Incarnate(
arje_incarnate::IncarnateError::NonExecutablePayload,
))
}
}
let card = node.to_card(i, workspace_label)?;
let stdio = ChildStdio {
stdin_fd: consumer_stdin_fd[i],
stdout_fd: producer_stdout_fd[i],
stderr_fd: None,
};
let outcome = incarnator
.incarnate_with(&card, stdio)
.map_err(CoreError::Incarnate)?;
let pid = outcome.pid;
pids.push((node.label.clone(), pid.as_raw()));
debug!(label = %node.label, pid = pid.as_raw(), "node incarnated");
}
let pipeline_id_for_flows = Ulid::new();
// Si tap=true, creamos un FlowChannel por edge para el data plane.
// Cada splitter pushea al sender del channel correspondiente.
let pipeline_id = pipeline_id_for_flows;
let mut flow_channels: Vec<crate::flow_channel::FlowChannel> = Vec::new();
let mut splitter_channels: Vec<Vec<Option<crate::flow_channel::FlowSender>>> =
Vec::with_capacity(splitter_specs.len());
let mut edge_socket_for_splitter: Vec<Vec<Option<std::path::PathBuf>>> = Vec::new();
for s in &splitter_specs {
let mut senders_per_edge = Vec::with_capacity(s.edges.len());
let mut paths_per_edge = Vec::with_capacity(s.edges.len());
for (i, _em) in s.edges.iter().enumerate() {
if !s.tap {
senders_per_edge.push(None);
paths_per_edge.push(None);
continue;
}
// Socket name = pipeline_id full (26 chars ULID) + edge_idx.
// ULID es único globalmente → cero colisiones entre runs.
// Edge_idx desambigua múltiples sockets del mismo pipeline.
// No incluimos from_label en el name (puede tener chars que
// no van en paths Unix — los hints van en `EdgeDiscernment`).
let id = format!("{}-{}", pipeline_id, i);
let mut socket = crate::flow_channel::default_flow_socket_path(&id);
// Fallback: si el path existe (raro — daemon crashed sin
// cleanup), agregar suffix numérico hasta encontrar libre.
let mut suffix = 1u32;
while socket.exists() {
let alt = format!("{id}-{suffix}");
socket = crate::flow_channel::default_flow_socket_path(&alt);
suffix += 1;
if suffix > 1000 {
warn!(orig = id, "flow socket collision: 1000 retries — using as-is");
break;
}
}
match crate::flow_channel::FlowChannel::with_replay_caps(
socket.clone(),
crate::flow_channel::ReplayCaps::new(spec.discern.replay_chunks, spec.discern.replay_bytes),
) {
Ok(fc) => {
senders_per_edge.push(Some(fc.sender_handle()));
paths_per_edge.push(Some(socket));
flow_channels.push(fc);
}
Err(e) => {
warn!(?e, "flow channel new failed");
senders_per_edge.push(None);
paths_per_edge.push(None);
}
}
}
splitter_channels.push(senders_per_edge);
edge_socket_for_splitter.push(paths_per_edge);
}
// Registramos los flow_channels en el manager AHORA, antes de await
// las tasks. Esto permite que clientes externos hagan `flow list` y
// se suscriban mientras el pipeline aún produce data.
if let Some(mgr) = &manager {
if !flow_channels.is_empty() {
let drained: Vec<crate::flow_channel::FlowChannel> = flow_channels.drain(..).collect();
mgr.retain_pipeline_flows(pipeline_id, drained).await;
}
}
// Spawn mergers + splitters después del incarnate. Cada task posee
// sus fds y los cierra al terminar (via Drop de OwnedFd).
let mut merger_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
for m in merger_specs {
merger_handles.push(spawn_merger(m));
}
let mut tap_handles: Vec<SplitterHandle> = Vec::new();
for (s, senders) in splitter_specs.into_iter().zip(splitter_channels.into_iter()) {
tap_handles.push(spawn_splitter(s, discerner.clone(), senders));
}
let mut edge_discernments = Vec::new();
for (h, paths) in tap_handles.into_iter().zip(edge_socket_for_splitter.into_iter()) {
match h.handle.await {
Ok(eds) => {
for (mut ed, path) in eds.into_iter().zip(paths.into_iter()) {
ed.flow_socket = path;
edge_discernments.push(ed);
}
}
Err(e) => warn!(?e, "splitter handle joined with error"),
}
}
for h in merger_handles {
if let Err(e) = h.await {
warn!(?e, "merger handle joined with error");
}
}
Ok(PipelineLaunch {
pipeline: pipeline_id,
command_pids: pids,
edge_discernments,
})
}
#[allow(dead_code)]
fn short_ulid(u: &Ulid) -> String {
let s = u.to_string();
s[s.len() - 6..].to_string()
}
#[derive(Debug, Clone)]
struct EdgeMeta {
from_label: String,
from_output: String,
to_label: String,
to_input: String,
}
struct SplitterSpec {
producer_r_fd: RawFd,
consumer_w_fds: Vec<RawFd>,
edges: Vec<EdgeMeta>,
tap: bool,
sample_bytes: usize,
/// Rate-limit en bytes/s (0 = sin limit). Tras cada chunk de `n`
/// bytes, splitter sleeps `n / max_bytes_per_sec` segundos.
max_bytes_per_sec: u64,
}
struct SplitterHandle {
handle: tokio::task::JoinHandle<Vec<EdgeDiscernment>>,
}
struct MergerSpec {
producer_r_fds: Vec<RawFd>,
consumer_w_fd: RawFd,
}
fn spawn_merger(spec: MergerSpec) -> tokio::task::JoinHandle<()> {
for fd in &spec.producer_r_fds {
set_nonblocking(*fd);
}
set_nonblocking(spec.consumer_w_fd);
// Patrón: una task lectora por cada producer reenvía bytes a un mpsc.
// El merger principal consume del mpsc y escribe al consumer.
// Esto evita el "block en reader idle" del enfoque round-robin sobre
// AsyncFd::ready() (los readers idle nunca dejan turno).
tokio::spawn(async move {
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
let nr = spec.producer_r_fds.len();
for fd in spec.producer_r_fds {
let tx = tx.clone();
tokio::spawn(async move {
// SAFETY: ownership transferida.
let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
let r = match AsyncFd::with_interest(owned, Interest::READABLE) {
Ok(a) => a,
Err(e) => {
warn!(?e, "merger reader AsyncFd");
return;
}
};
let mut buf = [0u8; 4096];
loop {
match async_read(&r, &mut buf).await {
Ok(0) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).await.is_err() {
break;
}
}
Err(_) => break,
}
}
// Drop de tx → cuando todos los readers cerraron, el rx
// recibe None y el merger termina.
});
}
drop(tx); // sólo los reader tasks tienen sus clones ahora.
// SAFETY: ownership transferida al task.
let w_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.consumer_w_fd) };
let w = match AsyncFd::with_interest(w_owned, Interest::WRITABLE) {
Ok(a) => a,
Err(e) => {
warn!(?e, "merger AsyncFd w");
return;
}
};
let mut total: u64 = 0;
while let Some(chunk) = rx.recv().await {
if async_write_all(&w, &chunk).await.is_err() {
return;
}
total += chunk.len() as u64;
}
debug!(bytes = total, readers = nr, "merger finished");
})
}
fn spawn_splitter(
spec: SplitterSpec,
discerner: Arc<DiscernPipeline>,
edge_senders: Vec<Option<crate::flow_channel::FlowSender>>,
) -> SplitterHandle {
set_nonblocking(spec.producer_r_fd);
for fd in &spec.consumer_w_fds {
set_nonblocking(*fd);
}
let handle = tokio::spawn(async move {
// SAFETY: ownership transferida al task.
let r_owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(spec.producer_r_fd) };
let r = match AsyncFd::with_interest(r_owned, Interest::READABLE) {
Ok(a) => a,
Err(e) => {
warn!(?e, "splitter AsyncFd r");
return Vec::new();
}
};
let mut writers: Vec<AsyncFd<std::os::fd::OwnedFd>> = Vec::with_capacity(spec.consumer_w_fds.len());
for fd in spec.consumer_w_fds {
let owned = unsafe { std::os::fd::OwnedFd::from_raw_fd_compat(fd) };
match AsyncFd::with_interest(owned, Interest::WRITABLE) {
Ok(a) => writers.push(a),
Err(e) => warn!(?e, "splitter AsyncFd w"),
}
}
let mut sample: Vec<u8> = Vec::with_capacity(spec.sample_bytes);
let mut buf = [0u8; 4096];
let mut total: u64 = 0;
let mut eof = false;
let mut bucket = if spec.max_bytes_per_sec > 0 {
Some(TokenBucket::new(spec.max_bytes_per_sec))
} else {
None
};
// Fase 1: sampling (sólo si tap=true) + replicación.
while !eof && (spec.tap && sample.len() < spec.sample_bytes) {
let n = match async_read(&r, &mut buf).await {
Ok(0) => { eof = true; 0 }
Ok(n) => n,
Err(e) => { warn!(?e, "splitter read"); break; }
};
if n == 0 { break; }
if spec.tap {
let take = n.min(spec.sample_bytes - sample.len());
sample.extend_from_slice(&buf[..take]);
}
// Token bucket: reserva ANTES de broadcast — si hay debt,
// sleep antes de mandar al subscriber.
if let Some(b) = bucket.as_mut() {
let wait = b.reserve(n as u64);
if !wait.is_zero() {
tokio::time::sleep(wait).await;
}
}
broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
total += n as u64;
}
let d = if spec.tap {
discerner.discern(&sample, &Hint { path: None, size_total: None })
} else {
None
};
// Fase 2: replicación pura.
while !eof {
let n = match async_read(&r, &mut buf).await {
Ok(0) => { eof = true; 0 }
Ok(n) => n,
Err(_) => break,
};
if n == 0 { break; }
if let Some(b) = bucket.as_mut() {
let wait = b.reserve(n as u64);
if !wait.is_zero() {
tokio::time::sleep(wait).await;
}
}
broadcast_chunk(&writers, &edge_senders, &buf[..n]).await;
total += n as u64;
}
debug!(bytes = total, consumers = writers.len(), "splitter finished");
// Mismo discernment para todos los edges del splitter (es el mismo
// stream replicado). Devolvemos N entries (una por edge) para que
// la UI/CLI los liste todos. flow_socket lo rellena el caller.
spec.edges
.into_iter()
.map(|em| EdgeDiscernment {
from_label: em.from_label,
from_output: em.from_output,
to_label: em.to_label,
to_input: em.to_input,
discernment: d.clone(),
flow_socket: None,
})
.collect()
});
SplitterHandle { handle }
}
/// Token-bucket real con capacidad de burst.
/// - `rate_bps`: tokens (bytes) por segundo de refill.
/// - `capacity`: máx tokens acumulables. Default = 1 segundo de rate.
/// - `tokens`: tokens disponibles (puede negativos para "debt").
/// - `last_refill`: para calcular cuántos refill desde la última call.
struct TokenBucket {
rate_bps: u64,
capacity: u64,
tokens: f64,
last_refill: std::time::Instant,
}
impl TokenBucket {
fn new(rate_bps: u64) -> Self {
Self {
rate_bps,
capacity: rate_bps, // 1 second worth of burst.
tokens: rate_bps as f64,
last_refill: std::time::Instant::now(),
}
}
/// Refill desde la última call según wall time. Reserva `cost`
/// tokens; si no alcanza, retorna el sleep necesario.
fn reserve(&mut self, cost: u64) -> std::time::Duration {
let now = std::time::Instant::now();
let elapsed_secs = now.duration_since(self.last_refill).as_secs_f64();
self.tokens = (self.tokens + elapsed_secs * self.rate_bps as f64)
.min(self.capacity as f64);
self.last_refill = now;
self.tokens -= cost as f64;
if self.tokens >= 0.0 {
std::time::Duration::ZERO
} else {
// Debt: tiempo para recuperar a 0 tokens.
let secs_needed = -self.tokens / self.rate_bps as f64;
std::time::Duration::from_secs_f64(secs_needed)
}
}
}
async fn broadcast_chunk(
writers: &[AsyncFd<std::os::fd::OwnedFd>],
edge_senders: &[Option<crate::flow_channel::FlowSender>],
data: &[u8],
) {
// Internal pipes a los consumers del pipeline.
for w in writers {
let _ = async_write_all(w, data).await;
}
// Externos: broadcast a subscribers vía FlowChannel.
// Cada edge tiene su propio sender (mismo data — el sample/discernment
// viaja por broadcast separados para que un subscriber por edge vea su
// stream específico).
if edge_senders.iter().any(|s| s.is_some()) {
let shared = std::sync::Arc::new(data.to_vec());
for s in edge_senders {
if let Some(s) = s {
let _ = s.send(shared.clone());
}
}
}
}
async fn async_read(
afd: &AsyncFd<std::os::fd::OwnedFd>,
buf: &mut [u8],
) -> std::io::Result<usize> {
loop {
let mut guard = afd.readable().await?;
let fd = afd.as_raw_fd();
// SAFETY: lectura sobre fd válido propiedad del AsyncFd.
let r = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, buf.len()) };
if r >= 0 {
return Ok(r as usize);
}
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
guard.clear_ready();
continue;
}
return Err(err);
}
}
async fn async_write_all(
afd: &AsyncFd<std::os::fd::OwnedFd>,
mut buf: &[u8],
) -> std::io::Result<()> {
while !buf.is_empty() {
let mut guard = afd.writable().await?;
let fd = afd.as_raw_fd();
// SAFETY: escritura sobre fd válido propiedad del AsyncFd.
let r = unsafe { libc::write(fd, buf.as_ptr() as *const _, buf.len()) };
if r > 0 {
buf = &buf[r as usize..];
continue;
}
if r == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::WriteZero,
"write 0",
));
}
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
guard.clear_ready();
continue;
}
return Err(err);
}
Ok(())
}
fn set_nonblocking(fd: RawFd) {
// SAFETY: fcntl con F_SETFL es seguro para fds válidos.
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL, 0);
if flags >= 0 {
libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
}
}
// Extension trait para abstraer la API de OwnedFd entre versiones (compat).
trait OwnedFdFromRawCompat: Sized {
unsafe fn from_raw_fd_compat(fd: RawFd) -> Self;
}
impl OwnedFdFromRawCompat for std::os::fd::OwnedFd {
unsafe fn from_raw_fd_compat(fd: RawFd) -> Self {
use std::os::fd::FromRawFd;
// SAFETY: el caller transfiere ownership de `fd` a la `OwnedFd`.
unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }
}
}
// Re-export para que el unused warning del AsRawFd se calle si no se usa.
#[allow(dead_code)]
fn _keep_raw(_: &dyn AsRawFd) {}
#[cfg(test)]
mod tests {
use super::*;
use card_core::Payload;
use arje_incarnate::IncarnatorConfig;
use shuma_card::{CommandRef, DiscernPolicy, FlowEdge, PipelineSpec, WorkspaceId};
fn cmd(label: &str, exec: &str, argv: &[&str]) -> CommandRef {
CommandRef {
label: label.into(),
payload: Payload::Native {
exec: exec.into(),
argv: argv.iter().map(|s| s.to_string()).collect(),
envp: vec![],
},
soma: Default::default(),
flows: Default::default(),
supervision: card_core::Supervision::OneShot,
}
}
#[tokio::test]
async fn pipeline_isolated_echo_to_cat_runs() {
let spec = PipelineSpec {
label: "echo-cat".into(),
workspace: WorkspaceId::new(),
nodes: vec![
cmd("p1", "/bin/echo", &["hola pipeline aislado"]),
cmd("p2", "/bin/cat", &[]),
],
edges: vec![FlowEdge {
from: 0,
from_output: "stdout".into(),
to: 1,
to_input: "stdin".into(),
}],
discern: DiscernPolicy::default(),
restart_on_failure: false,
restart_backoff_ms: 200,
restart_max_backoff_ms: 30_000,
restart_max: 0,
};
let disc = Arc::new(DiscernPipeline::default_pipeline());
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
assert_eq!(launch.command_pids.len(), 2);
// Cosecha.
for (_, pid) in &launch.command_pids {
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
}
}
#[tokio::test]
async fn pipeline_fanin_two_to_one() {
// 2 productores → 1 consumer (cat). El merger multiplexa.
let spec = PipelineSpec {
label: "fanin".into(),
workspace: WorkspaceId::new(),
nodes: vec![
cmd("p1", "/bin/echo", &["from-p1"]),
cmd("p2", "/bin/echo", &["from-p2"]),
cmd("c", "/bin/cat", &[]),
],
edges: vec![
FlowEdge {
from: 0,
from_output: "stdout".into(),
to: 2,
to_input: "stdin".into(),
},
FlowEdge {
from: 1,
from_output: "stdout".into(),
to: 2,
to_input: "stdin".into(),
},
],
discern: DiscernPolicy::default(),
restart_on_failure: false,
restart_backoff_ms: 200,
restart_max_backoff_ms: 30_000,
restart_max: 0,
};
let disc = Arc::new(DiscernPipeline::default_pipeline());
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
assert_eq!(launch.command_pids.len(), 3);
for (_, pid) in &launch.command_pids {
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
}
}
#[tokio::test]
async fn pipeline_fanout_one_to_two() {
// 1 productor (echo) → 2 consumers (wc -c). Splitter replica.
let spec = PipelineSpec {
label: "fanout".into(),
workspace: WorkspaceId::new(),
nodes: vec![
cmd("p", "/bin/echo", &["fanout-test"]),
cmd("c1", "/bin/cat", &[]),
cmd("c2", "/bin/cat", &[]),
],
edges: vec![
FlowEdge {
from: 0,
from_output: "stdout".into(),
to: 1,
to_input: "stdin".into(),
},
FlowEdge {
from: 0,
from_output: "stdout".into(),
to: 2,
to_input: "stdin".into(),
},
],
discern: DiscernPolicy::default(),
restart_on_failure: false,
restart_backoff_ms: 200,
restart_max_backoff_ms: 30_000,
restart_max: 0,
};
let disc = Arc::new(DiscernPipeline::default_pipeline());
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
let launch = run_pipeline(&spec, "ws", false, disc, inc, None).await.unwrap();
assert_eq!(launch.command_pids.len(), 3);
for (_, pid) in &launch.command_pids {
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
}
}
#[tokio::test]
async fn pipeline_isolated_with_tap_captures_discernment() {
let spec = PipelineSpec {
label: "json-cat".into(),
workspace: WorkspaceId::new(),
nodes: vec![
cmd("p1", "/bin/echo", &["{\"hello\": 1}"]),
cmd("p2", "/bin/cat", &[]),
],
edges: vec![FlowEdge {
from: 0,
from_output: "stdout".into(),
to: 1,
to_input: "stdin".into(),
}],
discern: DiscernPolicy {
sample_bytes: 4096,
enrich_producer: true,
replay_chunks: 32,
replay_bytes: 0,
max_bytes_per_sec: 0,
},
restart_on_failure: false,
restart_backoff_ms: 200,
restart_max_backoff_ms: 30_000,
restart_max: 0,
};
let disc = Arc::new(DiscernPipeline::default_pipeline());
let inc = Arc::new(Incarnator::new(IncarnatorConfig::default()));
let launch = run_pipeline(&spec, "ws", true, disc, inc, None).await.unwrap();
assert_eq!(launch.edge_discernments.len(), 1);
let d = &launch.edge_discernments[0];
let dis = d.discernment.as_ref().expect("discernment present");
assert_eq!(dis.mime.as_deref(), Some("application/json"));
// Cosecha.
for (_, pid) in &launch.command_pids {
let _ = nix::sys::wait::waitpid(nix::unistd::Pid::from_raw(*pid), None);
}
}
}
@@ -0,0 +1,318 @@
//! Pipelines, supervisores, flows y pipelines guardados.
use super::*;
impl WorkspaceManager {
pub fn new(cfg: IncarnatorConfig) -> Self {
Self {
inner: Arc::new(Mutex::new(Inner {
workspaces: HashMap::new(),
saved_pipelines: HashMap::new(),
pipeline_flows: HashMap::new(),
restart_specs: HashMap::new(),
pipeline_supervisors: HashMap::new(),
pending_pipeline_restarts: Vec::new(),
})),
incarnator: Arc::new(Incarnator::new(cfg)),
dirty: std::sync::atomic::AtomicBool::new(false),
}
}
/// Marca el manager como dirty. Cualquier mutación que afecta al
/// snapshot debería llamar esto.
#[inline]
pub(crate) fn mark_dirty(&self) {
self.dirty.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// True si hubo cambios desde el último `save_snapshot`. Útil para
/// chequeos cooperativos (ej. monitoring que pollea cada N).
pub fn is_dirty(&self) -> bool {
self.dirty.load(std::sync::atomic::Ordering::Relaxed)
}
/// Registra un supervisor para un pipeline con `restart_on_failure=true`.
/// El daemon llama esto tras `run_pipeline` para que `reap_dead` agregue
/// el pipeline a la cola de restart cuando algún command falle.
pub async fn register_pipeline_supervisor(
&self,
pipeline_id: Ulid,
workspace: WorkspaceId,
spec: PipelineSpec,
tap: bool,
) {
if !spec.restart_on_failure {
return;
}
tracing::debug!(%pipeline_id, label = %spec.label, "pipeline supervisor registered");
let mut g = self.inner.lock().await;
let initial_backoff = spec.restart_backoff_ms.max(50);
g.pipeline_supervisors.insert(
pipeline_id,
PipelineSupervisor {
workspace,
spec,
tap,
restart_count: 0,
current_backoff_ms: initial_backoff,
},
);
drop(g);
self.mark_dirty();
}
/// Variante que preserva backoff/count del supervisor anterior (para
/// re-registrar tras un restart sin perder el throttle acumulado).
pub async fn register_pipeline_supervisor_with_state(
&self,
pipeline_id: Ulid,
workspace: WorkspaceId,
spec: PipelineSpec,
tap: bool,
restart_count: u32,
current_backoff_ms: u64,
) {
if !spec.restart_on_failure {
return;
}
let mut g = self.inner.lock().await;
g.pipeline_supervisors.insert(
pipeline_id,
PipelineSupervisor {
workspace,
spec,
tap,
restart_count,
current_backoff_ms,
},
);
}
/// Drena la cola de pipelines pendientes de restart y retorna las
/// specs a relaunch. El daemon lo llama tras cada `reap_dead`.
///
/// Aplica `restart_max`: si el supervisor ya pasó el límite, no se
/// retorna y el supervisor se elimina (give-up). El backoff
/// preserva el valor actual; el daemon decide cuándo aplicar el
/// sleep antes del relaunch.
pub async fn take_pending_restarts(&self) -> Vec<PipelineSupervisor> {
let mut g = self.inner.lock().await;
let pending = std::mem::take(&mut g.pending_pipeline_restarts);
let mut out = Vec::with_capacity(pending.len());
for old_id in pending {
if let Some(mut sup) = g.pipeline_supervisors.remove(&old_id) {
if sup.spec.restart_max > 0 && sup.restart_count >= sup.spec.restart_max {
tracing::warn!(
label = %sup.spec.label,
restart_count = sup.restart_count,
max = sup.spec.restart_max,
"pipeline restart_max reached — giving up"
);
continue; // no relaunch, supervisor discarded.
}
sup.restart_count += 1;
out.push(sup);
}
}
out
}
/// Registra los comandos lanzados por un pipeline en el workspace.
/// Esto permite `pipeline_stop` (matar selectivamente sólo los pids
/// de un pipeline). `pipeline_id` se setea en cada CommandState.
pub async fn register_pipeline_commands(
&self,
workspace: WorkspaceId,
pipeline_id: Ulid,
commands: Vec<(String, i32)>,
) {
let mut g = self.inner.lock().await;
let Some(ws) = g.workspaces.get_mut(&workspace) else { return };
for (label, pid) in commands {
let cmd_id = Ulid::new();
ws.commands.insert(
cmd_id,
CommandState {
id: cmd_id,
label,
pid: Pid::from_raw(pid),
alive: true,
exit_status: None,
stdout: None,
stderr: None,
pipeline_id: Some(pipeline_id),
},
);
}
}
/// Detiene selectivamente los comandos de un pipeline. SIGTERM →
/// `grace` → SIGKILL. Devuelve cantidad reapeada. Si no hay comandos
/// del pipeline en ningún workspace, retorna 0.
pub async fn stop_pipeline(
&self,
pipeline_id: Ulid,
grace: std::time::Duration,
) -> u32 {
// 1) Recolectamos pids de ese pipeline en todos los workspaces.
let mut targets: Vec<Pid> = Vec::new();
{
let g = self.inner.lock().await;
for ws in g.workspaces.values() {
for cmd in ws.commands.values() {
if cmd.alive && cmd.pipeline_id == Some(pipeline_id) {
targets.push(cmd.pid);
}
}
}
}
if targets.is_empty() {
return 0;
}
let initial = if grace.is_zero() { Signal::SIGKILL } else { Signal::SIGTERM };
for pid in &targets {
let _ = kill(*pid, initial);
}
let mut reaped = 0u32;
let mut still = targets.clone();
let deadline = std::time::Instant::now() + grace;
let poll = std::time::Duration::from_millis(20);
while !still.is_empty() && std::time::Instant::now() < deadline {
still.retain(|pid| match waitpid(*pid, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::StillAlive) => true,
Ok(_) => {
reaped += 1;
false
}
Err(_) => false,
});
if !still.is_empty() {
tokio::time::sleep(poll).await;
}
}
for pid in &still {
let _ = kill(*pid, Signal::SIGKILL);
let _ = waitpid(*pid, None);
reaped += 1;
}
// Marcar como dead en estado in-memory.
let mut g = self.inner.lock().await;
for ws in g.workspaces.values_mut() {
for cmd in ws.commands.values_mut() {
if cmd.pipeline_id == Some(pipeline_id) && cmd.alive {
cmd.alive = false;
}
}
}
// Drop flows del pipeline.
g.pipeline_flows.remove(&pipeline_id);
info!(%pipeline_id, reaped, "pipeline stopped");
reaped
}
/// Retiene los FlowChannels de un pipeline para que sobrevivan al
/// fin del request. Drop = cierre del data plane.
pub async fn retain_pipeline_flows(
&self,
pipeline: Ulid,
flows: Vec<crate::flow_channel::FlowChannel>,
) {
self.inner.lock().await.pipeline_flows.insert(pipeline, flows);
}
/// Snapshot de counts agregados para health endpoint.
pub async fn health_counts(&self) -> HealthCounts {
let g = self.inner.lock().await;
let alive_workspaces = g.workspaces.len() as u32;
let alive_commands: u32 = g
.workspaces
.values()
.map(|ws| ws.commands.values().filter(|c| c.alive).count() as u32)
.sum();
let alive_pipelines = g.pipeline_supervisors.len() as u32;
let active_flows: u32 = g.pipeline_flows.values().map(|v| v.len() as u32).sum();
HealthCounts {
alive_workspaces,
alive_commands,
alive_pipelines,
active_flows,
}
}
/// Lista pipelines vivos con sus sockets activos.
pub async fn list_flow_pipelines(&self) -> Vec<(Ulid, Vec<std::path::PathBuf>)> {
let g = self.inner.lock().await;
g.pipeline_flows
.iter()
.map(|(id, flows)| {
(
*id,
flows.iter().map(|f| f.socket_path().to_path_buf()).collect(),
)
})
.collect()
}
/// Throughput per-socket: bytes_total + bytes_per_sec por flow socket.
pub async fn flow_throughput(&self) -> Vec<(std::path::PathBuf, u64, f64)> {
let g = self.inner.lock().await;
let mut out = Vec::new();
for flows in g.pipeline_flows.values() {
for fc in flows {
out.push((
fc.socket_path().to_path_buf(),
fc.meter().total_bytes(),
fc.meter().bytes_per_sec(),
));
}
}
out
}
/// Cierra el data plane de un pipeline (drop = remove_file de sockets).
pub async fn drop_pipeline_flows(&self, pipeline: Ulid) -> bool {
self.inner.lock().await.pipeline_flows.remove(&pipeline).is_some()
}
pub fn incarnator(&self) -> &Incarnator {
&self.incarnator
}
/// Handle Arc-clonable del Incarnator, para que el pipeline lo pueda
/// usar fuera del manager.
pub fn incarnator_handle(&self) -> Arc<Incarnator> {
self.incarnator.clone()
}
// -----------------------------------------------------------------
// Saved pipelines (definiciones nombradas, no runs)
// -----------------------------------------------------------------
/// Guarda (o reemplaza) un PipelineSpec bajo `name`.
pub async fn save_pipeline(&self, name: String, spec: PipelineSpec) {
self.inner.lock().await.saved_pipelines.insert(name, spec);
self.mark_dirty();
}
/// Devuelve los nombres de los pipelines guardados.
pub async fn list_saved_pipelines(&self) -> Vec<String> {
let g = self.inner.lock().await;
let mut v: Vec<String> = g.saved_pipelines.keys().cloned().collect();
v.sort();
v
}
/// Recupera el PipelineSpec guardado bajo `name`.
pub async fn get_saved_pipeline(&self, name: &str) -> Option<PipelineSpec> {
self.inner.lock().await.saved_pipelines.get(name).cloned()
}
/// Elimina un saved pipeline.
pub async fn drop_saved_pipeline(&self, name: &str) -> bool {
let existed = self.inner.lock().await.saved_pipelines.remove(name).is_some();
if existed {
self.mark_dirty();
}
existed
}
}
@@ -0,0 +1,236 @@
//! Lanzamiento de pipelines y reaping cooperativo de hijos muertos.
use super::*;
impl WorkspaceManager {
/// Lanza todas las Cards de un Pipeline. Devuelve (label, pid) por nodo.
/// La conexión via flows queda librada al broker (cuando haya integración
/// completa con sidecar; v1 sólo lanza).
pub async fn run_pipeline(
&self,
spec: &PipelineSpec,
) -> Result<Vec<(String, Pid)>, CoreError> {
spec.validate()?;
let workspace_label = {
let g = self.inner.lock().await;
let ws = g
.workspaces
.get(&spec.workspace)
.ok_or(CoreError::WorkspaceNotFound(spec.workspace))?;
ws.spec.label.clone()
};
let mut launched = Vec::new();
for (i, node) in spec.nodes.iter().enumerate() {
let card = node.to_card(i, &workspace_label)?;
let out = self.incarnator.incarnate(&card)?;
let mut g = self.inner.lock().await;
if let Some(ws) = g.workspaces.get_mut(&spec.workspace) {
ws.commands.insert(
card.id,
CommandState {
id: card.id,
label: node.label.clone(),
pid: out.pid,
alive: true,
exit_status: None,
stdout: None, // run_pipeline NO captura (conecta por pipes).
stderr: None,
pipeline_id: None,
},
);
}
launched.push((node.label.clone(), out.pid));
}
Ok(launched)
}
/// Cosecha hijos terminados (no-bloqueante). Llamar periódicamente desde
/// el daemon o ante SIGCHLD. Marca `alive=false` y guarda exit_status.
pub async fn reap_dead(self: &Arc<Self>) {
let mut to_restart: Vec<RestartSpec> = Vec::new();
let mut to_enforce_kill: Vec<WorkspaceId> = Vec::new();
{
let mut g = self.inner.lock().await;
for ws in g.workspaces.values_mut() {
for cmd in ws.commands.values_mut() {
if !cmd.alive {
continue;
}
match waitpid(cmd.pid, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::Exited(_, code)) => {
cmd.alive = false;
cmd.exit_status = Some(code);
}
Ok(WaitStatus::Signaled(_, sig, _)) => {
cmd.alive = false;
cmd.exit_status = Some(128 + (sig as i32));
}
_ => {}
}
}
}
// Quota enforcement: chequear breach por workspace y aplicar policy.
// Lo hacemos dentro del mismo lock para tener una lectura
// consistente; el kill real va fuera del lock.
for (ws_id, ws) in g.workspaces.iter() {
let rl = &ws.spec.soma.rlimits;
let qe = &ws.spec.quota_enforce;
// Sólo aplicamos si hay al menos una action != None.
if qe.mem == shuma_card::QuotaAction::None
&& qe.nproc == shuma_card::QuotaAction::None
{
continue;
}
// Medir RSS y nproc vivos sin pasar por workspace_stats
// (que tomaría el lock recursivo). Hacemos un read directo.
let alive: Vec<i32> = ws
.commands
.values()
.filter(|c| c.alive)
.map(|c| c.pid.as_raw())
.collect();
let nproc_alive = alive.len() as u32;
let mem_used: u64 = alive
.iter()
.filter_map(|pid| read_proc_rss(*pid))
.sum();
let mem_breach = matches!(rl.mem_bytes, Some(limit) if mem_used > limit);
let nproc_breach = matches!(rl.nproc, Some(limit) if nproc_alive > limit);
let mut kill_needed = false;
if mem_breach {
match qe.mem {
shuma_card::QuotaAction::Log => {
warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: memory");
}
shuma_card::QuotaAction::Kill => {
warn!(%ws_id, used = mem_used, limit = ?rl.mem_bytes, "quota breach: KILLING");
kill_needed = true;
}
_ => {}
}
}
if nproc_breach {
match qe.nproc {
shuma_card::QuotaAction::Log => {
warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: nproc");
}
shuma_card::QuotaAction::Kill => {
warn!(%ws_id, alive = nproc_alive, limit = ?rl.nproc, "quota breach: KILLING");
kill_needed = true;
}
_ => {}
}
}
if kill_needed {
to_enforce_kill.push(*ws_id);
}
}
// Pipeline supervisor: detectar pipelines cuyos comandos tienen
// failure. Marca para restart si tiene supervisor.
// Esto se hace cuando TODOS los comandos del pipeline están
// dead Y al menos uno tiene exit!=0 (sino podría disparar
// restart mientras otros comandos aún corren — incorrecto).
let supervisor_ids: Vec<Ulid> = g.pipeline_supervisors.keys().copied().collect();
for pipe_id in supervisor_ids {
// ¿Hay algún comando vivo de este pipeline?
let mut all_dead = true;
let mut any_failed = false;
for ws in g.workspaces.values() {
for cmd in ws.commands.values() {
if cmd.pipeline_id != Some(pipe_id) {
continue;
}
if cmd.alive {
all_dead = false;
} else if cmd.exit_status.map_or(false, |s| s != 0) {
any_failed = true;
}
}
}
if all_dead && any_failed {
// Push a queue si no estaba ya.
if !g.pending_pipeline_restarts.contains(&pipe_id) {
g.pending_pipeline_restarts.push(pipe_id);
}
}
}
// Detectar restart_specs cuyo command_id ya está dead con exit!=0.
let mut to_remove: Vec<Ulid> = Vec::new();
for (cmd_id, spec) in g.restart_specs.iter() {
let mut should_restart = false;
let mut should_drop = false;
'outer: for ws in g.workspaces.values() {
if let Some(cmd) = ws.commands.get(cmd_id) {
if !cmd.alive {
match cmd.exit_status {
Some(0) => should_drop = true,
Some(_) => should_restart = true,
None => {}
}
break 'outer;
}
}
}
if should_drop {
to_remove.push(*cmd_id);
} else if should_restart {
to_restart.push(spec.clone());
to_remove.push(*cmd_id);
}
}
for id in to_remove {
g.restart_specs.remove(&id);
}
}
// Quota enforcement: kill workspaces fuera del lock.
for ws_id in to_enforce_kill {
let _ = self.stop_with_grace(ws_id, std::time::Duration::ZERO).await;
}
// Schedule restart fuera del lock.
for mut spec in to_restart {
let mgr = self.clone();
let backoff = std::time::Duration::from_millis(spec.backoff_ms);
// Subir el backoff para la PRÓXIMA falla, no esta.
spec.backoff_ms = (spec.backoff_ms * 2).min(spec.max_backoff_ms);
spec.restart_count += 1;
let restart_n = spec.restart_count;
tokio::spawn(async move {
tokio::time::sleep(backoff).await;
info!(
backoff_ms = backoff.as_millis() as u64,
restart = restart_n,
"restarting failed command"
);
let workspace = spec.workspace;
if let Err(e) = mgr
.run_with_options(workspace, spec.exec.clone(), spec.argv.clone(), spec.envp.clone(), true)
.await
{
warn!(?e, "restart failed to launch");
return;
}
// Preservar backoff acumulado: localizar el nuevo command_id
// (el más reciente vivo en el workspace) y sobreescribir.
let new_cmd_id = {
let g = mgr.inner.lock().await;
g.workspaces.get(&workspace).and_then(|ws| {
ws.commands
.values()
.filter(|c| c.alive)
.max_by_key(|c| c.id)
.map(|c| c.id)
})
};
if let Some(new_id) = new_cmd_id {
let mut g = mgr.inner.lock().await;
if let Some(existing) = g.restart_specs.get_mut(&new_id) {
existing.backoff_ms = spec.backoff_ms;
existing.restart_count = spec.restart_count;
}
}
});
}
}
}
@@ -0,0 +1,210 @@
//! Resource accounting por workspace.
//!
//! Dos fuentes:
//! - **Per-proc** (`/proc/<pid>/status` + `stat`): suma RSS y CPU ticks de
//! los comandos vivos del workspace. Siempre disponible. Costo: O(N pids).
//! - **Cgroup v2** (`memory.current`, `cpu.stat`): un read por workspace si
//! `SomaSpec.cgroup.path` está y es leíble. Más preciso (incluye descendants).
//!
//! Si ambos están disponibles, devolvemos el cgroup (más preciso) y dejamos
//! el per-proc como `sample_via_proc`.
use std::path::Path;
use std::time::Instant;
#[derive(Debug, Clone, Default)]
pub struct WorkspaceStats {
pub commands_alive: u32,
pub commands_total: u32,
/// RSS sumado en bytes. `None` si no se pudo medir.
pub rss_bytes: Option<u64>,
/// High-water mark de RSS (peak alguna vez observado). Cgroup v2:
/// `memory.peak` (≥6.5). Per-proc: suma de `VmHWM` de cada pid.
pub rss_peak_bytes: Option<u64>,
/// Tiempo CPU acumulado en microsegundos. `None` si no se pudo medir.
pub cpu_usec: Option<u64>,
/// %CPU instantáneo derivado entre dos samples consecutivos. `None`
/// en el primer sample (no hay baseline). `100.0` = 1 core saturado.
/// `400.0` con 4 cores activos = la máquina al 100%.
pub cpu_percent: Option<f32>,
/// Cores online detectados (sysconf `_SC_NPROCESSORS_ONLN`). Útil
/// para normalizar `cpu_percent / cpu_cores` → 0..100 absoluto.
pub cpu_cores: u32,
/// Fuente del dato: "proc" | "cgroup" | "mixed".
pub source: String,
/// Wall-clock uptime del workspace en milisegundos.
pub uptime_ms: u64,
}
impl WorkspaceStats {
/// CPU% normalizado al 100% total de la máquina (no por core).
/// Útil para comparar workspaces independiente del paralelismo.
pub fn cpu_percent_total(&self) -> Option<f32> {
self.cpu_percent
.map(|p| if self.cpu_cores == 0 { p } else { p / self.cpu_cores as f32 })
}
}
/// Reporte de quotas: comparación entre el accounting real y los
/// `rlimits` declarados en `SomaSpec`. NO hace enforcement automático
/// en v1 — sólo accounting + reporting. El caller decide qué hacer.
#[derive(Debug, Clone, Default)]
pub struct QuotaReport {
/// Límite de memoria declarado (bytes). None = sin límite.
pub mem_limit: Option<u64>,
/// Límite de procesos declarado.
pub nproc_limit: Option<u32>,
/// Lista de violaciones detectadas (strings humano-legibles).
/// Empty = todo dentro de quota.
pub breaches: Vec<String>,
}
/// Detecta cores online runtime. Cacheado vía OnceLock — el valor no
/// cambia salvo hotplug, que es raro y aceptamos sample stale.
fn online_cores() -> u32 {
static CACHED: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
*CACHED.get_or_init(|| {
let n = unsafe { libc::sysconf(libc::_SC_NPROCESSORS_ONLN) };
if n > 0 { n as u32 } else { 1 }
})
}
/// Mide stats para un set de PIDs vivos + un path de cgroup opcional.
pub fn measure(
alive_pids: &[i32],
cgroup_path: Option<&Path>,
workspace_started: Instant,
) -> WorkspaceStats {
let mut rss_proc: u64 = 0;
let mut rss_peak_proc: u64 = 0;
let mut cpu_proc: u64 = 0;
let mut proc_ok = false;
for &pid in alive_pids {
if let Some((rss, peak, cpu)) = read_proc_pid(pid) {
rss_proc += rss;
rss_peak_proc += peak;
cpu_proc += cpu;
proc_ok = true;
}
}
let cgroup = cgroup_path.and_then(read_cgroup_stats);
let (rss, rss_peak, cpu, source) = match (cgroup, proc_ok) {
(Some(cg), _) => (Some(cg.rss), cg.rss_peak, Some(cg.cpu_usec), "cgroup".to_string()),
(None, true) => (
Some(rss_proc),
Some(rss_peak_proc),
Some(cpu_proc),
"proc".to_string(),
),
(None, false) => (None, None, None, "none".to_string()),
};
WorkspaceStats {
commands_alive: alive_pids.len() as u32,
commands_total: 0,
rss_bytes: rss,
rss_peak_bytes: rss_peak,
cpu_usec: cpu,
cpu_percent: None, // El caller lo rellena con el diff vs prev sample.
cpu_cores: online_cores(),
source,
uptime_ms: workspace_started.elapsed().as_millis() as u64,
}
}
struct CgroupStats {
rss: u64,
rss_peak: Option<u64>,
cpu_usec: u64,
}
/// Lee `(rss_bytes, rss_peak_bytes, cpu_usec)` de `/proc/<pid>/`. None si el proc desapareció.
fn read_proc_pid(pid: i32) -> Option<(u64, u64, u64)> {
let (rss_kb, hwm_kb) = {
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let mut rss = 0u64;
let mut hwm = 0u64;
for l in status.lines() {
if let Some(rest) = l.strip_prefix("VmRSS:") {
rss = rest
.trim()
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if let Some(rest) = l.strip_prefix("VmHWM:") {
hwm = rest
.trim()
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
}
}
(rss, hwm)
};
let cpu_usec = {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
// format: pid (comm) state ppid pgrp ... utime stime cutime cstime
// Cuidado: comm puede tener espacios y paréntesis. Buscamos la última `)`.
let end_comm = stat.rfind(')')?;
let after = &stat[end_comm + 1..];
let fields: Vec<&str> = after.split_whitespace().collect();
// Tras `)`, índice 0 = state, índice 11 = utime, 12 = stime.
let utime = fields.get(11).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let stime = fields.get(12).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let ticks = utime + stime;
// Convertimos ticks → microsegundos. SC_CLK_TCK típicamente 100.
let clk_tck = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }.max(1) as u64;
ticks * 1_000_000 / clk_tck
};
Some((rss_kb * 1024, hwm_kb * 1024, cpu_usec))
}
/// Lee `CgroupStats` del cgroup. None si no existe o no es leíble.
/// `memory.peak` requiere kernel ≥6.5; si falta, `rss_peak` queda None.
fn read_cgroup_stats(cgroup_path: &Path) -> Option<CgroupStats> {
let mem = std::fs::read_to_string(cgroup_path.join("memory.current"))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())?;
let cpu_stat = std::fs::read_to_string(cgroup_path.join("cpu.stat")).ok()?;
let cpu_usec = cpu_stat
.lines()
.find_map(|l| l.strip_prefix("usage_usec"))
.and_then(|s| s.split_whitespace().next())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let peak = std::fs::read_to_string(cgroup_path.join("memory.peak"))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok());
Some(CgroupStats {
rss: mem,
rss_peak: peak,
cpu_usec,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measure_with_no_pids_returns_zero() {
let stats = measure(&[], None, Instant::now());
assert_eq!(stats.commands_alive, 0);
assert_eq!(stats.rss_bytes, None);
assert_eq!(stats.source, "none");
}
#[test]
fn measure_self_pid_returns_data() {
let me = std::process::id() as i32;
let stats = measure(&[me], None, Instant::now());
assert_eq!(stats.commands_alive, 1);
// Nuestro propio RSS debería ser > 0.
assert!(stats.rss_bytes.unwrap_or(0) > 0);
assert_eq!(stats.source, "proc");
}
}
@@ -0,0 +1,308 @@
use super::*;
#[tokio::test]
async fn ttl_auto_stops_workspace() {
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let spec = WorkspaceSpec {
label: "ttl-test".into(),
soma: Default::default(),
permissions: Default::default(),
ttl: Some(std::time::Duration::from_millis(120)),
flow_dirs: vec![],
on_exit: shuma_card::ExitPolicy::Reap,
quota_enforce: Default::default(),
};
let (id, _) = mgr.create(spec).await.unwrap();
assert_eq!(mgr.list().await.len(), 1);
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
assert_eq!(
mgr.list().await.len(),
0,
"TTL expirado: workspace debe haber sido removido"
);
let _ = id;
}
#[tokio::test]
async fn create_and_list_workspace() {
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let spec = WorkspaceSpec {
label: "test".into(),
soma: Default::default(),
permissions: Default::default(),
ttl: None,
flow_dirs: vec![],
on_exit: shuma_card::ExitPolicy::Reap,
quota_enforce: Default::default(),
};
let (id, _w) = mgr.create(spec).await.unwrap();
let list = mgr.list().await;
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, id);
}
#[tokio::test]
async fn run_captures_stdout_to_log() {
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let spec = WorkspaceSpec {
label: "logs".into(),
soma: Default::default(),
permissions: Default::default(),
ttl: None,
flow_dirs: vec![],
on_exit: shuma_card::ExitPolicy::Reap,
quota_enforce: Default::default(),
};
let (id, _) = mgr.create(spec).await.unwrap();
let summary = mgr
.run(id, "/bin/echo".into(), vec!["captured-output".into()], vec![])
.await
.unwrap();
for _ in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
mgr.reap_dead().await;
let logs = mgr
.get_command_logs(id, summary.id, 0, LogStream::Stdout)
.await
.unwrap_or_default();
if !logs.is_empty() {
let s = String::from_utf8_lossy(&logs);
assert!(s.contains("captured-output"), "got: {s:?}");
return;
}
}
panic!("logs never captured");
}
#[tokio::test]
async fn run_captures_stderr_separately() {
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let spec = WorkspaceSpec {
label: "stderr".into(),
soma: Default::default(),
permissions: Default::default(),
ttl: None,
flow_dirs: vec![],
on_exit: shuma_card::ExitPolicy::Reap,
quota_enforce: Default::default(),
};
let (id, _) = mgr.create(spec).await.unwrap();
// sh -c "echo OUT; echo ERR >&2"
let summary = mgr
.run(
id,
"/bin/sh".into(),
vec!["-c".into(), "echo OUT; echo ERR >&2".into()],
vec![],
)
.await
.unwrap();
for _ in 0..50 {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
mgr.reap_dead().await;
let so = mgr
.get_command_logs(id, summary.id, 0, LogStream::Stdout)
.await
.unwrap_or_default();
let se = mgr
.get_command_logs(id, summary.id, 0, LogStream::Stderr)
.await
.unwrap_or_default();
if !so.is_empty() && !se.is_empty() {
let so_s = String::from_utf8_lossy(&so);
let se_s = String::from_utf8_lossy(&se);
assert!(so_s.contains("OUT"), "stdout: {so_s:?}");
assert!(se_s.contains("ERR"), "stderr: {se_s:?}");
assert!(!so_s.contains("ERR"), "stdout no debería tener ERR");
assert!(!se_s.contains("OUT"), "stderr no debería tener OUT");
return;
}
}
panic!("logs never captured on both streams");
}
#[tokio::test]
async fn restart_on_failure_relaunches_failing_command() {
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let spec = WorkspaceSpec {
label: "restart".into(),
soma: Default::default(),
permissions: Default::default(),
ttl: None,
flow_dirs: vec![],
on_exit: shuma_card::ExitPolicy::Reap,
quota_enforce: Default::default(),
};
let (id, _) = mgr.create(spec).await.unwrap();
// /bin/false sale con exit=1. Con restart_on_failure=true debería
// relanzarse al menos 1 vez (tras el backoff inicial de 200ms).
let summary = mgr
.run_with_options(id, "/bin/false".into(), vec![], vec![], true)
.await
.unwrap();
let original_id = summary.id;
// Esperamos ~500ms para que termine + reap + restart corra.
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
mgr.reap_dead().await;
let g = mgr.inner.lock().await;
if let Some(ws) = g.workspaces.get(&id) {
let new_cmds: Vec<_> = ws.commands.keys().filter(|k| **k != original_id).collect();
if !new_cmds.is_empty() {
// Hay un nuevo command_id → restart funcionó.
return;
}
}
}
panic!("restart never launched a new command");
}
#[tokio::test]
async fn pipeline_supervisor_queues_restart_on_failure() {
use shuma_card::{CommandRef, DiscernPolicy, PipelineSpec};
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let (ws_id, _) = mgr.create(WorkspaceSpec {
label: "psup".into(),
soma: Default::default(),
permissions: Default::default(),
ttl: None,
flow_dirs: vec![],
on_exit: shuma_card::ExitPolicy::Reap,
quota_enforce: Default::default(),
}).await.unwrap();
let spec = PipelineSpec {
label: "fail-pipeline".into(),
workspace: ws_id,
nodes: vec![CommandRef {
label: "boom".into(),
payload: card_core::Payload::Native {
exec: "/bin/false".into(),
argv: vec![],
envp: vec![],
},
soma: Default::default(),
flows: Default::default(),
supervision: card_core::Supervision::OneShot,
}],
edges: vec![],
discern: DiscernPolicy::default(),
restart_on_failure: true,
restart_backoff_ms: 200,
restart_max_backoff_ms: 30_000,
restart_max: 0,
};
let pipeline_id = ulid::Ulid::new();
// Simulamos lo que haría el daemon: registramos un comando como
// si fuera de pipeline. Usamos `register_pipeline_commands` con
// un pid fake — pero como reaper hace waitpid, mejor lanzar de verdad.
// Hack: usar /bin/false via run() y manualmente marcar pipeline_id.
let summary = mgr.run(ws_id, "/bin/false".into(), vec![], vec![]).await.unwrap();
// Marcar el comando con pipeline_id manualmente.
{
let mut g = mgr.inner.lock().await;
if let Some(ws) = g.workspaces.get_mut(&ws_id) {
if let Some(cmd) = ws.commands.get_mut(&summary.id) {
cmd.pipeline_id = Some(pipeline_id);
}
}
}
mgr.register_pipeline_supervisor(pipeline_id, ws_id, spec, true).await;
// Esperamos que reap detecte la falla y push a pending.
for _ in 0..40 {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
mgr.reap_dead().await;
let pending = mgr.take_pending_restarts().await;
if !pending.is_empty() {
assert_eq!(pending[0].spec.label, "fail-pipeline");
return;
}
}
panic!("supervisor never queued a restart");
}
#[tokio::test]
async fn quota_enforce_nproc_kill_terminates_commands() {
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let mut spec = WorkspaceSpec {
label: "qenforce".into(),
soma: Default::default(),
permissions: Default::default(),
ttl: None,
flow_dirs: vec![],
on_exit: shuma_card::ExitPolicy::Reap,
quota_enforce: shuma_card::QuotaEnforcement {
mem: shuma_card::QuotaAction::None,
nproc: shuma_card::QuotaAction::Kill,
},
};
spec.soma.rlimits.nproc = Some(1);
let (id, _) = mgr.create(spec).await.unwrap();
// Lanzo 2 procesos (cada uno sleep). nproc_limit=1 → breach inmediato.
let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
let _ = mgr.run(id, "/bin/sleep".into(), vec!["5".into()], vec![]).await.unwrap();
// Reaper detecta breach y mata workspace.
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
mgr.reap_dead().await;
let alive = mgr.list().await;
if alive.is_empty() {
return; // workspace removido por stop()
}
}
panic!("quota enforce kill never triggered");
}
#[tokio::test]
async fn workspace_stats_history_accumulates() {
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let spec = WorkspaceSpec {
label: "history".into(),
soma: Default::default(),
permissions: Default::default(),
ttl: None,
flow_dirs: vec![],
on_exit: shuma_card::ExitPolicy::Reap,
quota_enforce: Default::default(),
};
let (id, _) = mgr.create(spec).await.unwrap();
// Necesitamos al menos un comando vivo para que `measure` no
// retorne source=none (que igual se appendea, pero con stats vacíos).
let _ = mgr
.run(id, "/bin/sleep".into(), vec!["5".into()], vec![])
.await
.unwrap();
// Llamar stats 5 veces.
for _ in 0..5 {
let _ = mgr.workspace_stats(id).await;
}
let history = mgr.workspace_stats_history(id, 0).await.unwrap();
assert_eq!(history.len(), 5, "history debería tener 5 samples");
// tail=3 retorna los últimos 3.
let tail3 = mgr.workspace_stats_history(id, 3).await.unwrap();
assert_eq!(tail3.len(), 3);
// Cleanup.
let _ = mgr.stop_with_grace(id, std::time::Duration::ZERO).await;
}
#[tokio::test]
async fn run_true_in_workspace() {
let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig::default()));
let spec = WorkspaceSpec {
label: "exec".into(),
soma: Default::default(),
permissions: Default::default(),
ttl: None,
flow_dirs: vec![],
on_exit: shuma_card::ExitPolicy::Reap,
quota_enforce: Default::default(),
};
let (id, _) = mgr.create(spec).await.unwrap();
let summary = mgr
.run(id, "/bin/true".into(), vec![], vec![])
.await
.unwrap();
assert!(summary.pid > 0);
// Cosecha.
std::thread::sleep(std::time::Duration::from_millis(100));
mgr.reap_dead().await;
}
@@ -0,0 +1,414 @@
//! Workspaces: alta/baja, stats/quota, listado, comandos.
use super::*;
impl WorkspaceManager {
/// Label del workspace, si existe.
pub async fn workspace_label(&self, id: WorkspaceId) -> Option<String> {
self.inner
.lock()
.await
.workspaces
.get(&id)
.map(|w| w.spec.label.clone())
}
/// Compara accounting real (RSS, commands_alive) contra los rlimits
/// declarados en `SomaSpec`. Devuelve violaciones humanizadas. NO
/// hace enforcement automático.
pub async fn workspace_quota(&self, id: WorkspaceId) -> Option<stats::QuotaReport> {
let stats_now = self.workspace_stats(id).await?;
let g = self.inner.lock().await;
let ws = g.workspaces.get(&id)?;
let rl = &ws.spec.soma.rlimits;
let mut report = stats::QuotaReport {
mem_limit: rl.mem_bytes,
nproc_limit: rl.nproc,
breaches: Vec::new(),
};
if let (Some(limit), Some(used)) = (rl.mem_bytes, stats_now.rss_bytes) {
if used > limit {
report.breaches.push(format!(
"memory: {:.2} MiB > {:.2} MiB limit",
used as f64 / 1024.0 / 1024.0,
limit as f64 / 1024.0 / 1024.0,
));
}
}
if let Some(limit) = rl.nproc {
if stats_now.commands_alive > limit {
report.breaches.push(format!(
"nproc: {} alive > {} limit",
stats_now.commands_alive, limit
));
}
}
Some(report)
}
/// Estadísticas de recursos del workspace: RSS + CPU agregado de sus
/// comandos vivos. Lee `/proc/<pid>/` directamente; si el spec declara
/// `soma.cgroup.path`, también intenta el cgroup (más preciso, incluye
/// descendants).
///
/// `cpu_percent` se calcula entre samples consecutivos. Necesita ≥2
/// llamadas para tener un valor (la primera siempre retorna `None`).
pub async fn workspace_stats(&self, id: WorkspaceId) -> Option<stats::WorkspaceStats> {
let mut g = self.inner.lock().await;
let ws = g.workspaces.get_mut(&id)?;
let alive: Vec<i32> = ws
.commands
.values()
.filter(|c| c.alive)
.map(|c| c.pid.as_raw())
.collect();
let total = ws.commands.len() as u32;
let cgroup_path = if ws.spec.soma.cgroup.path.is_empty() {
None
} else {
Some(std::path::PathBuf::from(format!(
"/sys/fs/cgroup{}",
ws.spec.soma.cgroup.path
)))
};
let mut s = stats::measure(&alive, cgroup_path.as_deref(), ws.started);
s.commands_total = total;
// CPU%: diff entre el sample actual y el previo, dividido por
// wall time. 100% = 1 core saturado. >100% = varios cores.
let now = Instant::now();
if let Some(cpu_now) = s.cpu_usec {
if let Some((prev_t, prev_cpu)) = ws.last_cpu_sample {
let dt_us = now.duration_since(prev_t).as_micros() as u64;
let d_cpu = cpu_now.saturating_sub(prev_cpu);
if dt_us > 0 {
s.cpu_percent = Some(100.0 * d_cpu as f32 / dt_us as f32);
}
}
ws.last_cpu_sample = Some((now, cpu_now));
}
// Append a history (ring buffer cap).
if ws.stats_history.len() >= STATS_HISTORY_CAP {
ws.stats_history.pop_front();
}
ws.stats_history.push_back(s.clone());
Some(s)
}
/// Retorna las últimas N samples de stats (servidas desde el ring
/// buffer interno). Sobrevive restart del shell.
pub async fn workspace_stats_history(
&self,
id: WorkspaceId,
tail: usize,
) -> Option<Vec<stats::WorkspaceStats>> {
let g = self.inner.lock().await;
let ws = g.workspaces.get(&id)?;
let take = if tail == 0 { ws.stats_history.len() } else { tail };
let skip = ws.stats_history.len().saturating_sub(take);
Some(ws.stats_history.iter().skip(skip).cloned().collect())
}
pub async fn create(
self: &Arc<Self>,
spec: WorkspaceSpec,
) -> Result<(WorkspaceId, Vec<String>), CoreError> {
self.create_with_id(WorkspaceId::new(), spec).await
}
/// Variante que acepta el ID. Útil para restore_snapshot: preserva
/// ULIDs entre restarts, así clients que tracking workspace_id no se
/// rompen.
pub async fn create_with_id(
self: &Arc<Self>,
id: WorkspaceId,
spec: WorkspaceSpec,
) -> Result<(WorkspaceId, Vec<String>), CoreError> {
let card = spec.to_card(id)?;
let mut warnings = self.incarnator.dry_run(&card).warnings;
let ttl = spec.ttl;
// Si el workspace declara cgroup path Y rlimits, intentamos
// crear el cgroup y escribir memory.max/pids.max. El kernel
// hace OOM kill al exceder memory.max — enforcement automático
// sin policy adicional. Falla silenciosa si no hay delegation.
if !spec.soma.cgroup.path.is_empty() {
if let Ok(abs) = arje_incarnate::cgroup::ensure_cgroup(&spec.soma.cgroup) {
let applied =
arje_incarnate::cgroup::apply_rlimits_to_cgroup(&abs, &spec.soma.rlimits);
if !applied.is_empty() {
warnings.push(format!("cgroup limits applied: {}", applied.join(", ")));
}
}
}
let state = WorkspaceState {
id,
spec,
root_card: card,
commands: HashMap::new(),
started: Instant::now(),
last_cpu_sample: None,
stats_history: std::collections::VecDeque::with_capacity(STATS_HISTORY_CAP),
};
self.inner.lock().await.workspaces.insert(id, state);
self.mark_dirty();
info!(%id, ?ttl, "workspace created");
// Si tiene TTL, programar auto-stop. El task captura un weak ref
// al manager para no impedir que se dropée si el daemon termina.
if let Some(duration) = ttl {
let mgr_weak = Arc::downgrade(self);
tokio::spawn(async move {
tokio::time::sleep(duration).await;
if let Some(mgr) = mgr_weak.upgrade() {
let exists = mgr.inner.lock().await.workspaces.contains_key(&id);
if exists {
info!(%id, "workspace TTL expired — auto-stop");
let _ = mgr.stop(id).await;
}
}
});
}
Ok((id, warnings))
}
pub async fn list(&self) -> Vec<WorkspaceSnapshot> {
let g = self.inner.lock().await;
g.workspaces
.values()
.map(|w| WorkspaceSnapshot {
id: w.id,
label: w.spec.label.clone(),
commands: w.commands.len() as u32,
uptime_ms: w.started.elapsed().as_millis() as u64,
})
.collect()
}
pub async fn stop(&self, id: WorkspaceId) -> Result<u32, CoreError> {
self.stop_with_grace(id, std::time::Duration::from_millis(1000)).await
}
/// Variante con tiempo de gracia configurable. SIGTERM → espera `grace`
/// → SIGKILL si quedan vivos. `grace=0` = SIGKILL inmediato.
pub async fn stop_with_grace(
&self,
id: WorkspaceId,
grace: std::time::Duration,
) -> Result<u32, CoreError> {
let mut g = self.inner.lock().await;
let ws = g.workspaces.remove(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
// También limpiamos flow_channels del workspace si los hubiera —
// por workspace lo retenemos por pipeline, no por workspace.
drop(g);
self.mark_dirty();
// 1) SIGTERM (o SIGKILL si grace=0) a todos vivos.
let initial_signal = if grace.is_zero() { Signal::SIGKILL } else { Signal::SIGTERM };
let alive_pids: Vec<Pid> = ws
.commands
.values()
.filter(|c| c.alive)
.map(|c| c.pid)
.collect();
for pid in &alive_pids {
let _ = kill(*pid, initial_signal);
}
// 2) Esperar hasta `grace` haciendo polling WNOHANG.
let mut reaped = 0u32;
let mut still_alive: Vec<Pid> = alive_pids.clone();
let deadline = std::time::Instant::now() + grace;
let poll_interval = std::time::Duration::from_millis(20);
while !still_alive.is_empty() && std::time::Instant::now() < deadline {
still_alive.retain(|pid| match waitpid(*pid, Some(WaitPidFlag::WNOHANG)) {
Ok(WaitStatus::StillAlive) => true,
Ok(_) => {
reaped += 1;
false
}
Err(_) => false,
});
if !still_alive.is_empty() {
tokio::time::sleep(poll_interval).await;
}
}
// 3) SIGKILL forzoso a los que quedan, y wait blocking.
for pid in &still_alive {
let _ = kill(*pid, Signal::SIGKILL);
let _ = waitpid(*pid, None);
reaped += 1;
}
info!(
%id,
reaped,
grace_ms = grace.as_millis() as u64,
sigkilled = still_alive.len(),
"workspace stopped"
);
Ok(reaped)
}
/// Ejecuta un comando one-shot dentro de un workspace existente.
/// Captura stdout+stderr en un ring buffer accesible vía
/// [`get_command_logs`](Self::get_command_logs).
pub async fn run(
&self,
id: WorkspaceId,
exec: String,
argv: Vec<String>,
envp: Vec<(String, String)>,
) -> Result<CommandSummary, CoreError> {
self.run_with_options(id, exec, argv, envp, false).await
}
/// Variante con `restart_on_failure`: si el comando muere con
/// exit_status != 0, el reaper lo relauncha con backoff exponencial
/// (200ms → 400 → 800 → … cap 30s).
pub async fn run_with_options(
&self,
id: WorkspaceId,
exec: String,
argv: Vec<String>,
envp: Vec<(String, String)>,
restart_on_failure: bool,
) -> Result<CommandSummary, CoreError> {
let workspace_label = {
let g = self.inner.lock().await;
let ws = g.workspaces.get(&id).ok_or(CoreError::WorkspaceNotFound(id))?;
ws.spec.label.clone()
};
let cmd_ref = CommandRef {
label: format!("run-{}", short_ulid(&Ulid::new())),
payload: Payload::Native { exec, argv, envp },
soma: Default::default(),
flows: Default::default(),
supervision: Supervision::OneShot,
};
let card = cmd_ref.to_card(0, &workspace_label)?;
// Dos pipes O_CLOEXEC: uno para stdout, otro para stderr.
use std::os::fd::IntoRawFd;
let (sout_r, sout_w) =
nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
})?;
let (serr_r, serr_w) =
nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC).map_err(|e| {
CoreError::Incarnate(arje_incarnate::IncarnateError::Pipe(e))
})?;
let sout_r_fd = sout_r.into_raw_fd();
let sout_w_fd = sout_w.into_raw_fd();
let serr_r_fd = serr_r.into_raw_fd();
let serr_w_fd = serr_w.into_raw_fd();
let stdout_buf = logbuf::LogBuf::new();
let stderr_buf = logbuf::LogBuf::new();
let stdio = arje_incarnate::ChildStdio {
stdin_fd: None,
stdout_fd: Some(sout_w_fd),
stderr_fd: Some(serr_w_fd),
};
let out = self.incarnator.incarnate_with(&card, stdio)?;
let cmd_id = card.id;
let cmd_label = cmd_ref.label.clone();
let pid = out.pid;
spawn_log_drainer(sout_r_fd, stdout_buf.clone());
spawn_log_drainer(serr_r_fd, stderr_buf.clone());
let mut g = self.inner.lock().await;
if let Some(ws) = g.workspaces.get_mut(&id) {
ws.commands.insert(
cmd_id,
CommandState {
id: cmd_id,
label: cmd_label.clone(),
pid,
alive: true,
exit_status: None,
stdout: Some(stdout_buf),
stderr: Some(stderr_buf),
pipeline_id: None,
},
);
}
if restart_on_failure {
// Reextract exec/argv/envp del payload del CommandRef.
if let Payload::Native { exec, argv, envp } = &cmd_ref.payload {
g.restart_specs.insert(
cmd_id,
RestartSpec {
workspace: id,
exec: exec.clone(),
argv: argv.clone(),
envp: envp.clone(),
backoff_ms: 200,
max_backoff_ms: 30_000,
restart_count: 0,
},
);
}
}
for d in &out.degradations {
warn!(?d, %id, "command incarnation degradation");
}
Ok(CommandSummary {
id: cmd_id,
label: cmd_label,
pid: pid.as_raw(),
})
}
/// Devuelve el tail del log capturado para `(workspace, command)`.
/// `stream` selecciona stdout/stderr/both.
pub async fn get_command_logs(
&self,
workspace: WorkspaceId,
command: Ulid,
tail_bytes: usize,
stream: LogStream,
) -> Option<Vec<u8>> {
let g = self.inner.lock().await;
let ws = g.workspaces.get(&workspace)?;
let cmd = ws.commands.get(&command)?;
match stream {
LogStream::Stdout => cmd.stdout.as_ref().map(|lb| lb.tail(tail_bytes)),
LogStream::Stderr => cmd.stderr.as_ref().map(|lb| lb.tail(tail_bytes)),
LogStream::Both => {
let so = cmd.stdout.as_ref().map(|lb| lb.tail(tail_bytes)).unwrap_or_default();
let se = cmd.stderr.as_ref().map(|lb| lb.tail(tail_bytes)).unwrap_or_default();
let mut out = so;
out.extend_from_slice(&se);
Some(out)
}
}
}
/// Lista comandos de un workspace.
pub async fn list_commands(&self, workspace: WorkspaceId) -> Vec<CommandInfo> {
let g = self.inner.lock().await;
let Some(ws) = g.workspaces.get(&workspace) else { return Vec::new() };
let mut out: Vec<CommandInfo> = ws
.commands
.values()
.map(|c| CommandInfo {
id: c.id,
label: c.label.clone(),
pid: c.pid.as_raw(),
alive: c.alive,
exit_status: c.exit_status,
log_bytes: c.stdout.as_ref().map(|l| l.written_total()).unwrap_or(0)
+ c.stderr.as_ref().map(|l| l.written_total()).unwrap_or(0),
})
.collect();
// Orden estable por ULID (temporal).
out.sort_by_key(|c| c.id);
out
}
}
@@ -0,0 +1,17 @@
[package]
name = "shuma-exec"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "shuma — ejecutor de comandos del shell con salida en streaming: lanza un proceso y entrega stdout/stderr línea a línea por un canal. Agnóstico de UI."
[dependencies]
# `zerocopy` → `splice` (volcado pipe→archivo sin copia). `OFlag` (para los
# pipes de tee por etapa con `O_CLOEXEC` vía `pipe2`) vive bajo la feature
# `fs`, que el workspace ya habilita — `unistd::pipe2` no necesita feature.
nix = { workspace = true, features = ["zerocopy"] }
# PTY allocation cross-platform — usado por `Exec::Pty` para los
# comandos TUI (vim, htop, less, claude-code, etc.).
portable-pty = { workspace = true }
@@ -0,0 +1,9 @@
# shuma-exec
> Ejecutor de comandos de [shuma](../../README.md).
Envuelve `std::process::Command` con job-control, signal handling, env del session. Pipes, redirects, &&/||.
## Deps
- [`shuma-core`](../shuma-core/README.md), `nix`
@@ -0,0 +1,9 @@
# shuma-exec
> Command executor of [shuma](../../README.md).
Wraps `std::process::Command` with job-control, signal handling, session env. Pipes, redirects, &&/||.
## Deps
- [`shuma-core`](../shuma-core/README.md), `nix`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
[package]
name = "shuma-history"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "shuma — historial durable de comandos (JSONL append-only) con navegación y búsqueda fuzzy."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
directories = { workspace = true }
nucleo-matcher = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
@@ -0,0 +1,9 @@
# shuma-history
> History con búsqueda fuzzy de [shuma](../../README.md).
`fzf`-style por defecto + per-directory history opcional. Privacy mode (`shopt -s nohist` equivalente).
## Deps
- [`shuma-core`](../shuma-core/README.md)
@@ -0,0 +1,9 @@
# shuma-history
> Fuzzy-search history of [shuma](../../README.md).
`fzf`-style by default + optional per-directory history. Privacy mode (`shopt -s nohist` equivalent).
## Deps
- [`shuma-core`](../shuma-core/README.md)
@@ -0,0 +1,430 @@
//! `shuma-history` — historial **durable** de comandos.
//!
//! Independiente del historial vivo de [`shuma_session::WorkSession`]
//! (que guarda salida completa para la vista en curso): aquí sólo se
//! persisten *líneas* con su contexto mínimo, en un fichero JSONL
//! appendonly fácil de leer, rotar y compartir entre sesiones.
//!
//! Diseño:
//!
//! - **JSONL** (`{"line":...,"cwd":...,"exit":...,"started":...,"duration_ms":...}`).
//! Una entrada por línea, appendonly — robusto frente a kills/crashes.
//! - **Sin lock global**: las escrituras usan `OpenOptions::append`, que
//! en Linux son atómicas hasta `PIPE_BUF` (4096 B). Las líneas largas
//! no se entrelazan en la práctica con los tamaños típicos de un
//! comando.
//! - **Búsqueda fuzzy** con [`nucleo_matcher`] — mismo matcher que
//! helixeditor: rápido, Unicodecorrect, ranking estable.
//! - **Dedup**: política configurable; por defecto se ignora el
//! duplicado *consecutivo* (estilo bash `HISTCONTROL=ignoredups`).
#![forbid(unsafe_code)]
use std::fs::{File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Una entrada del historial durable — la línea y su contexto mínimo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entry {
/// La línea de comandos tal como se ejecutó.
pub line: String,
/// Directorio en que se lanzó.
pub cwd: String,
/// Código de salida (`None` si nunca terminó —p. ej. crash del shell).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit: Option<i32>,
/// Segundo Unix en que arrancó.
pub started: u64,
/// Duración en milisegundos (`None` si no terminó).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
impl Entry {
/// Construye una entrada nueva con la línea y el cwd; resto a vacío.
pub fn new(line: impl Into<String>, cwd: impl Into<String>, started: u64) -> Self {
Self {
line: line.into(),
cwd: cwd.into(),
exit: None,
started,
duration_ms: None,
}
}
}
/// Política de deduplicación al añadir entradas nuevas.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DedupPolicy {
/// Guardar todas las entradas sin deduplicar.
None,
/// Saltar el comando si es idéntico al último guardado (`ignoredups`).
IgnoreConsecutive,
/// Borrar duplicados previos cuando se vuelve a ver el mismo comando.
EraseDups,
}
impl Default for DedupPolicy {
fn default() -> Self {
Self::IgnoreConsecutive
}
}
/// Dirección de navegación por el historial.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Nav {
/// Hacia atrás en el tiempo (flecha arriba).
Older,
/// Hacia adelante en el tiempo (flecha abajo).
Newer,
}
/// Historial durable cargado en memoria, con su fichero de respaldo.
pub struct History {
path: PathBuf,
entries: Vec<Entry>,
dedup: DedupPolicy,
/// Cuántas líneas inválidas se descartaron al cargar.
skipped: usize,
}
impl History {
/// Ruta por defecto: `$XDG_DATA_HOME/shuma/history.jsonl` (o el
/// equivalente Linux/macOS/Windows según [`directories`]). `None` si
/// el SO no expone un directorio de datos para el usuario.
pub fn default_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "shuma")
.map(|d| d.data_dir().join("history.jsonl"))
}
/// Abre (o crea) el historial en `path`. Carga todas las entradas
/// existentes. Las líneas inválidas se cuentan en `skipped` pero no
/// abortan la apertura — el shell debe poder arrancar incluso con
/// historial parcialmente corrupto.
pub fn open(path: impl Into<PathBuf>) -> io::Result<Self> {
let path = path.into();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut entries = Vec::new();
let mut skipped = 0usize;
if path.exists() {
let f = File::open(&path)?;
for line in BufReader::new(f).lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Entry>(&line) {
Ok(e) => entries.push(e),
Err(_) => skipped += 1,
}
}
}
Ok(Self { path, entries, dedup: DedupPolicy::default(), skipped })
}
/// Política de deduplicación activa.
pub fn dedup(&self) -> DedupPolicy {
self.dedup
}
/// Cambia la política de deduplicación.
pub fn set_dedup(&mut self, policy: DedupPolicy) {
self.dedup = policy;
}
/// Líneas inválidas descartadas en la última apertura.
pub fn skipped_on_load(&self) -> usize {
self.skipped
}
/// Cantidad de entradas en memoria.
pub fn len(&self) -> usize {
self.entries.len()
}
/// `true` si no hay entradas.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Ruta del fichero de respaldo.
pub fn path(&self) -> &Path {
&self.path
}
/// Entradas en orden cronológico (más antigua primero).
pub fn entries(&self) -> &[Entry] {
&self.entries
}
/// Última entrada.
pub fn last(&self) -> Option<&Entry> {
self.entries.last()
}
/// Añade una entrada — aplica la política de dedup y persiste a
/// disco. Devuelve `true` si efectivamente se añadió (no era un
/// duplicado descartable). Las entradas con `line` vacía se ignoran.
pub fn append(&mut self, entry: Entry) -> io::Result<bool> {
if entry.line.trim().is_empty() {
return Ok(false);
}
match self.dedup {
DedupPolicy::None => {}
DedupPolicy::IgnoreConsecutive => {
if self.entries.last().is_some_and(|e| e.line == entry.line) {
return Ok(false);
}
}
DedupPolicy::EraseDups => {
self.entries.retain(|e| e.line != entry.line);
self.rewrite_file()?;
}
}
self.write_one(&entry)?;
self.entries.push(entry);
Ok(true)
}
/// Actualiza la última entrada con el código de salida y la duración
/// cuando el comando termina. Persiste reescribiendo el fichero.
pub fn finalize_last(&mut self, exit: i32, duration_ms: u64) -> io::Result<()> {
if let Some(last) = self.entries.last_mut() {
last.exit = Some(exit);
last.duration_ms = Some(duration_ms);
self.rewrite_file()?;
}
Ok(())
}
/// Navegación por el historial — devuelve el `(index, entry)`
/// correspondiente a moverse `dir` desde el cursor actual. El cursor
/// `None` parte "del final" (por debajo de la última entrada).
/// Convención: el índice 0 es la **entrada más reciente**, y avanza
/// hacia el pasado al subir el cursor.
pub fn navigate(&self, cursor: Option<usize>, dir: Nav) -> Option<(usize, &Entry)> {
if self.entries.is_empty() {
return None;
}
let next = match (cursor, dir) {
(None, Nav::Older) => 0,
(None, Nav::Newer) => return None,
(Some(i), Nav::Older) => i + 1,
(Some(0), Nav::Newer) => return None,
(Some(i), Nav::Newer) => i - 1,
};
if next >= self.entries.len() {
return None;
}
let entry = &self.entries[self.entries.len() - 1 - next];
Some((next, entry))
}
/// Búsqueda fuzzy sobre el campo `line`. Devuelve hasta `limit`
/// resultados ordenados por score descendente. Una `query` vacía
/// devuelve las entradas más recientes.
pub fn fuzzy_search(&self, query: &str, limit: usize) -> Vec<&Entry> {
if limit == 0 || self.entries.is_empty() {
return Vec::new();
}
if query.trim().is_empty() {
return self.entries.iter().rev().take(limit).collect();
}
use nucleo_matcher::{
pattern::{CaseMatching, Normalization, Pattern},
Config, Matcher,
};
let mut matcher = Matcher::new(Config::DEFAULT);
let pat = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
let mut scored: Vec<(u32, usize)> = Vec::new();
let mut buf = Vec::new();
for (idx, e) in self.entries.iter().enumerate() {
buf.clear();
let hay = nucleo_matcher::Utf32Str::new(&e.line, &mut buf);
if let Some(score) = pat.score(hay, &mut matcher) {
scored.push((score, idx));
}
}
// Score desc, y a igualdad de score, el más reciente primero.
scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
scored
.into_iter()
.take(limit)
.map(|(_, i)| &self.entries[i])
.collect()
}
// --- I/O ---
fn write_one(&self, entry: &Entry) -> io::Result<()> {
let mut f = OpenOptions::new().create(true).append(true).open(&self.path)?;
let mut s = serde_json::to_string(entry).map_err(io::Error::other)?;
s.push('\n');
f.write_all(s.as_bytes())?;
f.flush()
}
fn rewrite_file(&self) -> io::Result<()> {
// Escritura atómica vía rename — nunca dejamos un historial a medias.
let tmp = self.path.with_extension("jsonl.tmp");
{
let mut f = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
for e in &self.entries {
let mut s = serde_json::to_string(e).map_err(io::Error::other)?;
s.push('\n');
f.write_all(s.as_bytes())?;
}
f.flush()?;
}
std::fs::rename(tmp, &self.path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn h(dir: &Path) -> History {
History::open(dir.join("history.jsonl")).unwrap()
}
#[test]
fn empty_history_round_trip() {
let d = tempdir().unwrap();
let h1 = h(d.path());
assert!(h1.is_empty());
let h2 = h(d.path());
assert!(h2.is_empty());
}
#[test]
fn append_persists_across_reopen() {
let d = tempdir().unwrap();
{
let mut h = h(d.path());
h.append(Entry::new("ls", "/tmp", 1000)).unwrap();
h.append(Entry::new("pwd", "/tmp", 1001)).unwrap();
}
let h = h(d.path());
assert_eq!(h.len(), 2);
assert_eq!(h.entries()[0].line, "ls");
assert_eq!(h.entries()[1].line, "pwd");
}
#[test]
fn ignore_consecutive_dedup_skips_repeats() {
let d = tempdir().unwrap();
let mut h = h(d.path());
assert!(h.append(Entry::new("ls", "/tmp", 1)).unwrap());
assert!(!h.append(Entry::new("ls", "/tmp", 2)).unwrap());
assert!(h.append(Entry::new("pwd", "/tmp", 3)).unwrap());
assert!(h.append(Entry::new("ls", "/tmp", 4)).unwrap());
assert_eq!(h.len(), 3);
}
#[test]
fn erase_dups_purges_prior_copies() {
let d = tempdir().unwrap();
let mut h = h(d.path());
h.set_dedup(DedupPolicy::EraseDups);
h.append(Entry::new("ls", "/tmp", 1)).unwrap();
h.append(Entry::new("pwd", "/tmp", 2)).unwrap();
h.append(Entry::new("ls", "/tmp", 3)).unwrap();
assert_eq!(h.len(), 2);
assert_eq!(h.entries()[0].line, "pwd");
assert_eq!(h.entries()[1].line, "ls");
}
#[test]
fn empty_line_is_ignored() {
let d = tempdir().unwrap();
let mut h = h(d.path());
assert!(!h.append(Entry::new("", "/tmp", 1)).unwrap());
assert!(!h.append(Entry::new(" ", "/tmp", 2)).unwrap());
assert!(h.is_empty());
}
#[test]
fn finalize_writes_exit_and_duration() {
let d = tempdir().unwrap();
{
let mut h = h(d.path());
h.append(Entry::new("sleep 1", "/tmp", 0)).unwrap();
h.finalize_last(0, 1000).unwrap();
}
let h = h(d.path());
assert_eq!(h.last().unwrap().exit, Some(0));
assert_eq!(h.last().unwrap().duration_ms, Some(1000));
}
#[test]
fn navigate_walks_from_newest_to_oldest() {
let d = tempdir().unwrap();
let mut h = h(d.path());
for (i, l) in ["a", "b", "c"].iter().enumerate() {
h.append(Entry::new(*l, "/tmp", i as u64)).unwrap();
}
// Empezando sin cursor, Older da la más reciente.
let (i0, e0) = h.navigate(None, Nav::Older).unwrap();
assert_eq!((i0, e0.line.as_str()), (0, "c"));
let (i1, e1) = h.navigate(Some(i0), Nav::Older).unwrap();
assert_eq!((i1, e1.line.as_str()), (1, "b"));
let (i2, e2) = h.navigate(Some(i1), Nav::Older).unwrap();
assert_eq!((i2, e2.line.as_str()), (2, "a"));
// En el extremo no hay más viejas.
assert!(h.navigate(Some(i2), Nav::Older).is_none());
// Volvemos hacia las nuevas.
let (i3, e3) = h.navigate(Some(i2), Nav::Newer).unwrap();
assert_eq!((i3, e3.line.as_str()), (1, "b"));
}
#[test]
fn fuzzy_search_ranks_matches() {
let d = tempdir().unwrap();
let mut h = h(d.path());
for l in ["cargo build --release", "cargo test", "git status", "cargo run"] {
h.append(Entry::new(l, "/tmp", 0)).unwrap();
}
let hits = h.fuzzy_search("cgo", 10);
// Las 3 entradas con "cargo" matchean; "git status" no.
assert_eq!(hits.len(), 3);
assert!(hits.iter().all(|e| e.line.contains("cargo")));
}
#[test]
fn empty_query_returns_most_recent_first() {
let d = tempdir().unwrap();
let mut h = h(d.path());
for l in ["a", "b", "c", "d"] {
h.append(Entry::new(l, "/tmp", 0)).unwrap();
}
let hits = h.fuzzy_search("", 2);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].line, "d");
assert_eq!(hits[1].line, "c");
}
#[test]
fn corrupt_lines_are_skipped_not_fatal() {
let d = tempdir().unwrap();
let path = d.path().join("history.jsonl");
std::fs::write(
&path,
"{\"line\":\"ok\",\"cwd\":\"/tmp\",\"started\":1}\ngarbage\n{\"line\":\"ok2\",\"cwd\":\"/tmp\",\"started\":2}\n",
)
.unwrap();
let h = History::open(&path).unwrap();
assert_eq!(h.len(), 2);
assert_eq!(h.skipped_on_load(), 1);
}
}
@@ -0,0 +1,11 @@
[package]
name = "shuma-infer"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "shuma — motor de inferencia de intenciones secuenciales: detecta patrones de comandos repetidos en el historial y abstrae sus argumentos variables."
[dependencies]
serde = { workspace = true }
@@ -0,0 +1,9 @@
# shuma-infer
> Inferencia para [`intent`](../shuma-intent/README.md) de [shuma](../../README.md).
Prompts + heurísticas que rodean al LLM. Mantiene el modelo "confinado" al lenguaje shell.
## Deps
- [`shuma-intent`](../shuma-intent/README.md)
@@ -0,0 +1,9 @@
# shuma-infer
> Inference for [`intent`](../shuma-intent/README.md) of [shuma](../../README.md).
Prompts + heuristics around the LLM. Keeps the model "confined" to shell command language.
## Deps
- [`shuma-intent`](../shuma-intent/README.md)
@@ -0,0 +1,460 @@
//! `shuma-infer` — el motor de inferencia de intenciones secuenciales.
//!
//! El shell observa cómo trabajas. Cuando una *coreografía* de comandos
//! se repite —`cd` a un proyecto, `git pull`, `cargo build`— este motor
//! la detecta, la abstrae (los argumentos que cambian se vuelven
//! variables) y la ofrece como un patrón reutilizable. Automatización
//! que nace de la repetición orgánica, no de escribir scripts.
//!
//! Es agnóstico y determinista: recibe el historial reducido a
//! [`CommandRecord`]s y devuelve [`EmergingPattern`]s. No toca disco, ni
//! la red, ni ningún frontend — el shell se encarga de eso.
//!
//! ```text
//! historial ──► detect_patterns ──► [EmergingPattern]
//! · firma de binarios (ventana deslizante)
//! · sólo ventanas 100% exitosas
//! · abstracción: args que varían → Varies
//! · se quedan los patrones maximales
//! ```
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Un comando ejecutado, reducido a lo que importa para inferir.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CommandRecord {
/// El binario invocado — la primera palabra de la línea.
pub binary: String,
/// Los argumentos, en orden.
pub args: Vec<String>,
/// Directorio en que se ejecutó.
pub cwd: String,
/// Si terminó con éxito (código 0).
pub success: bool,
}
impl CommandRecord {
/// Reduce una línea de comando a un registro. La división es simple
/// (`split_whitespace`) — suficiente para comparar firmas.
pub fn parse(line: &str, cwd: impl Into<String>, success: bool) -> Self {
let mut words = line.split_whitespace().map(str::to_string);
let binary = words.next().unwrap_or_default();
Self { binary, args: words.collect(), cwd: cwd.into(), success }
}
}
/// Ajustes del detector.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct InferConfig {
/// Largo mínimo de una secuencia para considerarla patrón.
pub min_len: usize,
/// Largo máximo de ventana a buscar.
pub max_len: usize,
/// Cuántas veces debe repetirse una firma para emerger.
pub min_occurrences: usize,
}
impl Default for InferConfig {
fn default() -> Self {
Self { min_len: 2, max_len: 5, min_occurrences: 2 }
}
}
/// Los argumentos de un paso del patrón, tras la abstracción.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StepArgs {
/// Los argumentos son idénticos en todas las ocurrencias.
Fixed(Vec<String>),
/// Los argumentos cambian entre ocurrencias — son una variable.
Varies,
}
/// Un paso abstracto del patrón: el binario + sus argumentos.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PatternStep {
pub binary: String,
pub args: StepArgs,
}
impl PatternStep {
/// Renderiza el paso para mostrarlo — `"git pull"`, `"cd <…>"`.
pub fn render(&self) -> String {
match &self.args {
StepArgs::Fixed(a) if a.is_empty() => self.binary.clone(),
StepArgs::Fixed(a) => format!("{} {}", self.binary, a.join(" ")),
StepArgs::Varies => format!("{} <…>", self.binary),
}
}
}
/// Un patrón de comandos que emergió del historial.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmergingPattern {
/// Firma: la secuencia de binarios.
pub signature: Vec<String>,
/// Pasos abstractos — para mostrar al usuario.
pub steps: Vec<PatternStep>,
/// Las líneas reales de la ocurrencia más reciente — ejecutables.
pub example: Vec<String>,
/// Cuántas veces apareció el patrón.
pub occurrences: usize,
/// Directorios donde arrancó el patrón, sin repetir.
pub directories: Vec<String>,
}
impl EmergingPattern {
/// Puntaje de interés: más largo y más frecuente, más arriba.
pub fn score(&self) -> usize {
self.occurrences * self.signature.len()
}
/// Nombre sugerido para el patrón — los binarios significativos
/// (sin el `cd` inicial) unidos por `+`.
pub fn suggested_name(&self) -> String {
let significant: Vec<&str> = self
.signature
.iter()
.filter(|b| b.as_str() != "cd")
.map(String::as_str)
.collect();
if significant.is_empty() {
self.signature.join("+")
} else {
significant.join("+")
}
}
}
/// `true` si `needle` aparece como sub-secuencia contigua de `haystack`.
fn contains_subslice(haystack: &[String], needle: &[String]) -> bool {
needle.len() <= haystack.len() && haystack.windows(needle.len()).any(|w| w == needle)
}
/// Construye el patrón abstracto a partir de su firma y las posiciones
/// donde ocurrió.
fn build_pattern(
history: &[CommandRecord],
signature: &[String],
starts: &[usize],
) -> EmergingPattern {
let len = signature.len();
let mut steps = Vec::with_capacity(len);
for i in 0..len {
// Argumentos de este paso a lo largo de todas las ocurrencias.
let first = &history[starts[0] + i].args;
let all_same = starts.iter().all(|&s| &history[s + i].args == first);
let args = if all_same {
StepArgs::Fixed(first.clone())
} else {
StepArgs::Varies
};
steps.push(PatternStep { binary: signature[i].clone(), args });
}
// La ocurrencia más reciente da las líneas reales, ejecutables.
let last = *starts.iter().max().expect("hay ocurrencias");
let example: Vec<String> = (0..len)
.map(|i| {
let c = &history[last + i];
if c.args.is_empty() {
c.binary.clone()
} else {
format!("{} {}", c.binary, c.args.join(" "))
}
})
.collect();
// El directorio "de trabajo" de una ocurrencia: el cwd de su último
// comando — para entonces todos los `cd` ya se hicieron.
let mut directories: Vec<String> = Vec::new();
for &s in starts {
let d = &history[s + len - 1].cwd;
if !directories.contains(d) {
directories.push(d.clone());
}
}
EmergingPattern {
signature: signature.to_vec(),
steps,
example,
occurrences: starts.len(),
directories,
}
}
/// Detecta los patrones de comandos repetidos en `history`.
///
/// Sólo cuentan las ventanas cuyos comandos terminaron todos con éxito.
/// Se devuelven los patrones *maximales* (uno contenido en otro más
/// largo no se reporta), ordenados por puntaje descendente.
pub fn detect_patterns(history: &[CommandRecord], cfg: &InferConfig) -> Vec<EmergingPattern> {
// firma → posiciones de inicio de las ventanas que la producen.
let mut windows: BTreeMap<Vec<String>, Vec<usize>> = BTreeMap::new();
for len in cfg.min_len..=cfg.max_len {
if history.len() < len {
break;
}
for start in 0..=history.len() - len {
let win = &history[start..start + len];
if !win.iter().all(|c| c.success) {
continue; // una ventana con un fallo no es un patrón
}
let signature: Vec<String> = win.iter().map(|c| c.binary.clone()).collect();
windows.entry(signature).or_default().push(start);
}
}
// Firmas que se repiten lo suficiente.
let qualifying: Vec<(Vec<String>, Vec<usize>)> = windows
.into_iter()
.filter(|(_, starts)| starts.len() >= cfg.min_occurrences)
.collect();
// Sólo las maximales: una firma contenida en otra más larga que
// también califica se descarta (la larga la subsume).
let mut patterns: Vec<EmergingPattern> = qualifying
.iter()
.filter(|(sig, _)| {
!qualifying
.iter()
.any(|(other, _)| other.len() > sig.len() && contains_subslice(other, sig))
})
.map(|(sig, starts)| build_pattern(history, sig, starts))
.collect();
patterns.sort_by(|a, b| {
b.score()
.cmp(&a.score())
.then(a.signature.cmp(&b.signature))
});
patterns
}
/// Predice la continuación de un patrón en curso.
///
/// Mira el final del historial `recent`: si sus últimos comandos
/// coinciden con el prefijo de la firma de algún patrón, devuelve las
/// líneas que faltan para completarlo —tomadas de la ocurrencia más
/// reciente, así son ejecutables—. Ante varios, gana el patrón cuyo
/// prefijo coincidente sea más largo. Es lo que alimenta el "ghosting".
/// Devuelve `(índice del patrón en `patterns`, líneas de continuación)`.
pub fn predict_next(
recent: &[CommandRecord],
patterns: &[EmergingPattern],
) -> Option<(usize, Vec<String>)> {
let bins: Vec<&str> = recent.iter().map(|r| r.binary.as_str()).collect();
// best = (longitud del prefijo coincidente, índice del patrón).
let mut best: Option<(usize, usize)> = None;
for (pi, p) in patterns.iter().enumerate() {
// Tiene que quedar al menos un paso por predecir.
let max_k = p.signature.len().saturating_sub(1).min(bins.len());
for k in (1..=max_k).rev() {
let tail = &bins[bins.len() - k..];
let prefix_matches = p
.signature
.iter()
.take(k)
.map(String::as_str)
.eq(tail.iter().copied());
if prefix_matches {
if best.map(|(bk, _)| k > bk).unwrap_or(true) {
best = Some((k, pi));
}
break;
}
}
}
best.map(|(k, pi)| (pi, patterns[pi].example[k..].to_vec()))
}
#[cfg(test)]
mod tests {
use super::*;
/// Atajo: un `CommandRecord` exitoso.
fn ok(line: &str, cwd: &str) -> CommandRecord {
CommandRecord::parse(line, cwd, true)
}
#[test]
fn parse_splits_binary_and_args() {
let r = CommandRecord::parse("git commit -m mensaje", "/p", true);
assert_eq!(r.binary, "git");
assert_eq!(r.args, vec!["commit", "-m", "mensaje"]);
}
#[test]
fn detects_a_repeated_sequence() {
// cd → git pull → cargo build, dos veces, en dos directorios.
let history = vec![
ok("cd /proj/a", "/home"),
ok("git pull", "/proj/a"),
ok("cargo build", "/proj/a"),
ok("cd /proj/b", "/home"),
ok("git pull", "/proj/b"),
ok("cargo build", "/proj/b"),
];
let patterns = detect_patterns(&history, &InferConfig::default());
assert_eq!(patterns.len(), 1);
let p = &patterns[0];
assert_eq!(p.signature, vec!["cd", "git", "cargo"]);
assert_eq!(p.occurrences, 2);
}
#[test]
fn abstracts_varying_arguments() {
let history = vec![
ok("cd /proj/a", "/home"),
ok("git pull", "/proj/a"),
ok("cd /proj/b", "/home"),
ok("git pull", "/proj/b"),
];
let patterns = detect_patterns(&history, &InferConfig::default());
let p = &patterns[0];
// El `cd` cambia de argumento → Varies; `git pull` es constante.
assert_eq!(p.steps[0].args, StepArgs::Varies);
assert_eq!(p.steps[1].args, StepArgs::Fixed(vec!["pull".into()]));
assert_eq!(p.steps[0].render(), "cd <…>");
assert_eq!(p.steps[1].render(), "git pull");
}
#[test]
fn example_is_the_most_recent_occurrence() {
let history = vec![
ok("cd /proj/a", "/home"),
ok("git pull", "/proj/a"),
ok("cd /proj/b", "/home"),
ok("git pull", "/proj/b"),
];
let p = &detect_patterns(&history, &InferConfig::default())[0];
// Las líneas reales y ejecutables de la última ocurrencia.
assert_eq!(p.example, vec!["cd /proj/b", "git pull"]);
}
#[test]
fn a_failed_command_breaks_the_pattern() {
let history = vec![
ok("cd /proj/a", "/home"),
ok("git pull", "/proj/a"),
ok("cd /proj/b", "/home"),
CommandRecord::parse("git pull", "/proj/b", false), // falló
];
// Sólo una ventana [cd, git] exitosa → no se repite → sin patrón.
assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
}
#[test]
fn no_repetition_yields_no_patterns() {
let history = vec![
ok("ls", "/a"),
ok("pwd", "/a"),
ok("date", "/a"),
];
assert!(detect_patterns(&history, &InferConfig::default()).is_empty());
}
#[test]
fn longer_pattern_subsumes_its_subsequences() {
// [cd, git, cargo] repetido → no se reporta también [cd, git].
let history = vec![
ok("cd /a", "/h"),
ok("git pull", "/a"),
ok("cargo build", "/a"),
ok("cd /b", "/h"),
ok("git pull", "/b"),
ok("cargo build", "/b"),
];
let patterns = detect_patterns(&history, &InferConfig::default());
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].signature.len(), 3);
}
#[test]
fn directories_are_collected() {
let history = vec![
ok("cd /a", "/home"),
ok("git pull", "/a"),
ok("cd /b", "/work"),
ok("git pull", "/b"),
];
let p = &detect_patterns(&history, &InferConfig::default())[0];
// El directorio de cada ocurrencia es el de su último comando.
assert_eq!(p.directories, vec!["/a", "/b"]);
}
#[test]
fn suggested_name_drops_the_cd() {
let history = vec![
ok("cd /a", "/h"),
ok("git pull", "/a"),
ok("cargo build", "/a"),
ok("cd /b", "/h"),
ok("git pull", "/b"),
ok("cargo build", "/b"),
];
let p = &detect_patterns(&history, &InferConfig::default())[0];
assert_eq!(p.suggested_name(), "git+cargo");
}
/// Mundo de prueba: el patrón cd → git pull → cargo build, visto dos
/// veces, y la lista de patrones que produce.
fn pattern_world() -> Vec<EmergingPattern> {
let history = vec![
ok("cd /a", "/h"),
ok("git pull", "/a"),
ok("cargo build", "/a"),
ok("cd /b", "/h"),
ok("git pull", "/b"),
ok("cargo build", "/b"),
];
detect_patterns(&history, &InferConfig::default())
}
#[test]
fn predicts_the_rest_after_a_cd() {
let patterns = pattern_world();
// El usuario acaba de hacer `cd` → se predicen los pasos que faltan.
let recent = vec![ok("cd /nuevo", "/h")];
let (_, next) = predict_next(&recent, &patterns).unwrap();
assert_eq!(next, vec!["git pull", "cargo build"]);
}
#[test]
fn prediction_shrinks_as_the_pattern_advances() {
let patterns = pattern_world();
let recent = vec![ok("cd /nuevo", "/h"), ok("git pull", "/nuevo")];
let (_, next) = predict_next(&recent, &patterns).unwrap();
assert_eq!(next, vec!["cargo build"]);
}
#[test]
fn no_prediction_when_nothing_matches() {
let patterns = pattern_world();
let recent = vec![ok("ls", "/h"), ok("pwd", "/h")];
assert!(predict_next(&recent, &patterns).is_none());
}
#[test]
fn score_ranks_longer_and_more_frequent_higher() {
let short = EmergingPattern {
signature: vec!["a".into(), "b".into()],
steps: vec![],
example: vec![],
occurrences: 2,
directories: vec![],
};
let long = EmergingPattern {
signature: vec!["a".into(), "b".into(), "c".into()],
steps: vec![],
example: vec![],
occurrences: 3,
directories: vec![],
};
assert!(long.score() > short.score());
}
}
@@ -0,0 +1,11 @@
[package]
name = "shuma-intent"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "shuma — núcleo agnóstico del shell: parser de intenciones (tokens %cN/%pN) + grafo de contexto de la sesión."
[dependencies]
serde = { workspace = true }
@@ -0,0 +1,9 @@
# shuma-intent
> Intent → comando (predictor) de [shuma](../../README.md).
Toma texto natural y predice el comando shell. Usa [`pluma-llm`](../../../../00_unanchay/pluma/pluma-llm/README.md). Sugiere; nunca ejecuta sin confirmación.
## Deps
- [`pluma-llm-core`](../../../../00_unanchay/pluma/pluma-llm-core/README.md)
@@ -0,0 +1,9 @@
# shuma-intent
> Intent → command (predictor) of [shuma](../../README.md).
Takes natural-language text and predicts the matching shell command. Uses [`pluma-llm`](../../../../00_unanchay/pluma/pluma-llm/README.md). Suggests; never executes without confirmation.
## Deps
- [`pluma-llm-core`](../../../../00_unanchay/pluma/pluma-llm-core/README.md)
@@ -0,0 +1,175 @@
//! Grafo de contexto de una sesión de shuma.
//!
//! Registra cada intención ejecutada como un nodo `%cN`; al terminar, el
//! nodo expone su buffer de salida `%pN`. El grafo permite resolver las
//! referencias del prompt, validar intenciones nuevas antes de ejecutar,
//! y colapsar nodos exitosos para la quietud visual.
use crate::parse::{Intention, Ref};
use serde::{Deserialize, Serialize};
/// Estado de un nodo del grafo de contexto.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NodeStatus {
Running,
Ok,
Failed,
}
/// Un comando registrado en la sesión.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandNode {
/// Identificador `%cN`.
pub id: u32,
/// Texto de la intención original.
pub intention: String,
/// Buffer `%pN` producido como salida, si el comando ya terminó.
pub output_buffer: Option<u32>,
pub status: NodeStatus,
/// Colapsado en la UI (nodo exitoso retraído por quietud visual).
pub collapsed: bool,
/// Bytes del buffer de salida (para dimensionar el grafo visual).
pub output_bytes: u64,
}
/// Grafo de intenciones y flujos de una sesión de shell.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionGraph {
commands: Vec<CommandNode>,
next_command: u32,
next_buffer: u32,
}
impl Default for SessionGraph {
fn default() -> Self {
Self { commands: Vec::new(), next_command: 1, next_buffer: 1 }
}
}
impl SessionGraph {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.commands.len()
}
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
pub fn commands(&self) -> &[CommandNode] {
&self.commands
}
/// Registra una intención nueva en estado `Running`. Devuelve su `%cN`.
pub fn record(&mut self, intention: impl Into<String>) -> u32 {
let id = self.next_command;
self.next_command += 1;
self.commands.push(CommandNode {
id,
intention: intention.into(),
output_buffer: None,
status: NodeStatus::Running,
collapsed: false,
output_bytes: 0,
});
id
}
/// Marca un comando como terminado y le asigna un buffer de salida.
/// Devuelve el `%pN` asignado, o `None` si el `%cN` no existe.
pub fn complete(&mut self, command_id: u32, ok: bool, output_bytes: u64) -> Option<u32> {
let buffer = self.next_buffer;
let node = self.commands.iter_mut().find(|c| c.id == command_id)?;
node.status = if ok { NodeStatus::Ok } else { NodeStatus::Failed };
node.output_bytes = output_bytes;
node.output_buffer = Some(buffer);
self.next_buffer += 1;
Some(buffer)
}
/// Resuelve una referencia a su nodo de comando.
pub fn resolve(&self, r: Ref) -> Option<&CommandNode> {
match r {
Ref::Command(n) => self.commands.iter().find(|c| c.id == n),
Ref::Buffer(n) => self.commands.iter().find(|c| c.output_buffer == Some(n)),
}
}
/// Referencias de la intención que NO se pueden resolver en esta
/// sesión. Vacío = la intención es ejecutable (validación previa
/// del prompt).
pub fn dangling_refs(&self, intention: &Intention) -> Vec<Ref> {
intention
.refs()
.into_iter()
.filter(|r| self.resolve(*r).is_none())
.collect()
}
/// Colapsa los nodos exitosos (quietud visual: los flujos que ya
/// funcionaron se retraen).
pub fn collapse_succeeded(&mut self) {
for c in &mut self.commands {
if c.status == NodeStatus::Ok {
c.collapsed = true;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_assigns_increasing_command_ids() {
let mut g = SessionGraph::new();
assert_eq!(g.record("cat a"), 1);
assert_eq!(g.record("cat b"), 2);
assert_eq!(g.len(), 2);
}
#[test]
fn complete_assigns_buffer_and_status() {
let mut g = SessionGraph::new();
let c1 = g.record("cat data.json");
let buf = g.complete(c1, true, 2_400_000).expect("c1 existe");
assert_eq!(buf, 1);
let node = g.resolve(Ref::Command(c1)).unwrap();
assert_eq!(node.status, NodeStatus::Ok);
assert_eq!(node.output_buffer, Some(1));
assert_eq!(node.output_bytes, 2_400_000);
// Se resuelve también por su buffer.
assert!(g.resolve(Ref::Buffer(1)).is_some());
}
#[test]
fn dangling_refs_validates_an_intention() {
let mut g = SessionGraph::new();
let c1 = g.record("cat data.json");
g.complete(c1, true, 100).unwrap(); // produce %p1
// `%p1` existe, `%p9` no.
let ok = Intention::parse("sort | %p1");
assert!(g.dangling_refs(&ok).is_empty());
let bad = Intention::parse("sort | %p9");
assert_eq!(g.dangling_refs(&bad), vec![Ref::Buffer(9)]);
}
#[test]
fn collapse_only_retracts_successful_nodes() {
let mut g = SessionGraph::new();
let c1 = g.record("ok cmd");
let c2 = g.record("fail cmd");
let _c3 = g.record("running cmd");
g.complete(c1, true, 0).unwrap();
g.complete(c2, false, 0).unwrap();
g.collapse_succeeded();
assert!(g.resolve(Ref::Command(c1)).unwrap().collapsed);
assert!(!g.resolve(Ref::Command(c2)).unwrap().collapsed);
}
}
@@ -0,0 +1,21 @@
//! `shuma-intent` — núcleo agnóstico del shell shuma.
//!
//! El shell shuma trabaja con **intenciones**, no comandos sueltos: cada
//! línea del prompt es una [`Intention`] (etapas conectadas por pipes,
//! con tokens de referencia `%cN`/`%pN`). El [`SessionGraph`] mantiene el
//! historial como un grafo de contexto navegable: cada comando es un
//! nodo, cada salida un buffer intermedio referenciable.
//!
//! Todo acá es lógica pura y serializable — el front-end GPUI (las tres
//! zonas: RUN, SENS y el lienzo central) lo rehidrata; la ejecución real
//! la hace `sandokan`.
#![forbid(unsafe_code)]
pub mod parse;
pub mod graph;
pub mod macros;
pub use graph::{CommandNode, NodeStatus, SessionGraph};
pub use macros::{Macro, MacroBook};
pub use parse::{Intention, Ref, Stage};
@@ -0,0 +1,114 @@
//! Macros del shell — la barra de ejecución [RUN].
//!
//! Una macro es una secuencia de intenciones nombrada y opcionalmente
//! mapeada a una tecla física (F1-F3...). Son serializables: la spec
//! pide que sean compartibles entre sesiones y entre usuarios.
use serde::{Deserialize, Serialize};
/// Una macro: un nombre, una tecla opcional y las intenciones que dispara.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Macro {
pub name: String,
/// Tecla física que la dispara (`"F1"`, `"F2"`, ...). `None` = sin atajo.
pub key: Option<String>,
/// Líneas de prompt que ejecuta, en orden.
pub intentions: Vec<String>,
}
impl Macro {
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into(), key: None, intentions: Vec::new() }
}
/// Builder: asigna una tecla.
pub fn bind(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
/// Builder: agrega una intención.
pub fn step(mut self, intention: impl Into<String>) -> Self {
self.intentions.push(intention.into());
self
}
}
/// Colección de macros de la barra [RUN]. Serializable para compartir.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MacroBook {
macros: Vec<Macro>,
}
impl MacroBook {
pub fn new() -> Self {
Self::default()
}
/// Agrega (o reemplaza por nombre) una macro.
pub fn insert(&mut self, m: Macro) {
if let Some(slot) = self.macros.iter_mut().find(|x| x.name == m.name) {
*slot = m;
} else {
self.macros.push(m);
}
}
pub fn len(&self) -> usize {
self.macros.len()
}
pub fn is_empty(&self) -> bool {
self.macros.is_empty()
}
pub fn all(&self) -> &[Macro] {
&self.macros
}
/// Macro mapeada a una tecla física dada.
pub fn by_key(&self, key: &str) -> Option<&Macro> {
self.macros.iter().find(|m| m.key.as_deref() == Some(key))
}
/// Macro por nombre exacto.
pub fn by_name(&self, name: &str) -> Option<&Macro> {
self.macros.iter().find(|m| m.name == name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn macro_builder_composes() {
let m = Macro::new("deploy")
.bind("F2")
.step("cargo build --release")
.step("scp target/release/app host:/srv");
assert_eq!(m.key.as_deref(), Some("F2"));
assert_eq!(m.intentions.len(), 2);
}
#[test]
fn book_lookup_by_key_and_name() {
let mut book = MacroBook::new();
book.insert(Macro::new("build").bind("F1").step("cargo build"));
book.insert(Macro::new("clean").bind("F3").step("cargo clean"));
assert_eq!(book.len(), 2);
assert_eq!(book.by_key("F1").unwrap().name, "build");
assert_eq!(book.by_key("F3").unwrap().name, "clean");
assert!(book.by_key("F9").is_none());
assert!(book.by_name("clean").is_some());
}
#[test]
fn insert_replaces_by_name() {
let mut book = MacroBook::new();
book.insert(Macro::new("x").step("v1"));
book.insert(Macro::new("x").step("v2"));
assert_eq!(book.len(), 1);
assert_eq!(book.by_name("x").unwrap().intentions, vec!["v2"]);
}
}
@@ -0,0 +1,118 @@
//! Parser de intenciones del prompt de shuma.
//!
//! Una "intención" es una línea del prompt: etapas separadas por `|`.
//! Cada etapa es un comando a ejecutar, o un token de referencia a un
//! resultado previo de la sesión (`%cN` un comando, `%pN` un buffer
//! intermedio). Ej: `ssh nodo 'cat data.json' | %p1 | sort`.
use serde::{Deserialize, Serialize};
/// Referencia a un resultado de la sesión.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Ref {
/// `%cN` — un comando registrado de la sesión.
Command(u32),
/// `%pN` — un buffer intermedio producido por un comando.
Buffer(u32),
}
impl Ref {
/// Parsea un token aislado `%c3` / `%p12`. `None` si no es un token.
pub fn parse(token: &str) -> Option<Ref> {
let rest = token.trim().strip_prefix('%')?;
let mut chars = rest.chars();
let kind = chars.next()?;
let num: u32 = chars.as_str().parse().ok()?;
match kind {
'c' => Some(Ref::Command(num)),
'p' => Some(Ref::Buffer(num)),
_ => None,
}
}
}
/// Una etapa del pipeline de una intención.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Stage {
/// Comando a ejecutar (texto crudo; puede ser `ssh host '...'`).
Exec(String),
/// Inyección de un resultado previo de la sesión.
Inject(Ref),
}
/// Una intención parseada: etapas conectadas por pipes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Intention {
pub stages: Vec<Stage>,
}
impl Intention {
/// Parsea una línea del prompt. Las etapas se separan por `|`; una
/// etapa que es exactamente un token `%pN`/`%cN` es `Inject`, el
/// resto es `Exec`. Las etapas vacías se descartan.
pub fn parse(line: &str) -> Intention {
let stages = line
.split('|')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| match Ref::parse(s) {
Some(r) => Stage::Inject(r),
None => Stage::Exec(s.to_string()),
})
.collect();
Intention { stages }
}
/// `true` si la intención no tiene etapas (línea vacía).
pub fn is_empty(&self) -> bool {
self.stages.is_empty()
}
/// Todas las referencias que la intención consume.
pub fn refs(&self) -> Vec<Ref> {
self.stages
.iter()
.filter_map(|s| match s {
Stage::Inject(r) => Some(*r),
Stage::Exec(_) => None,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ref_tokens() {
assert_eq!(Ref::parse("%c3"), Some(Ref::Command(3)));
assert_eq!(Ref::parse("%p12"), Some(Ref::Buffer(12)));
assert_eq!(Ref::parse(" %p1 "), Some(Ref::Buffer(1)));
assert_eq!(Ref::parse("sort"), None);
assert_eq!(Ref::parse("%x9"), None);
assert_eq!(Ref::parse("%p"), None);
}
#[test]
fn parses_the_spec_example() {
// ssh nodo 'cat data.json' | %p1 | sort
let i = Intention::parse("ssh nodo 'cat data.json' | %p1 | sort");
assert_eq!(i.stages.len(), 3);
assert_eq!(i.stages[0], Stage::Exec("ssh nodo 'cat data.json'".into()));
assert_eq!(i.stages[1], Stage::Inject(Ref::Buffer(1)));
assert_eq!(i.stages[2], Stage::Exec("sort".into()));
}
#[test]
fn refs_extracts_only_injections() {
let i = Intention::parse("cat x | %p1 | %c2 | wc -l");
assert_eq!(i.refs(), vec![Ref::Buffer(1), Ref::Command(2)]);
}
#[test]
fn empty_line_is_empty_intention() {
assert!(Intention::parse(" ").is_empty());
assert!(Intention::parse("| |").is_empty());
}
}
@@ -0,0 +1,14 @@
[package]
name = "shuma-line"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "shuma — el cerebro del input del shell: analiza la línea de comandos (bash), la clasifica para resaltado, autocompleta y separa los pipes. Agnóstico de GUI/TUI."
[dependencies]
serde = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
@@ -0,0 +1,9 @@
# shuma-line
> Readline (edición · completion · highlight) de [shuma](../../README.md).
Implementación propia (no `rustyline`) que comparte el ropebuffer con [`text-editor`](../../../llimphi/widgets/text-editor/README.md). Highlight de sintaxis en vivo, autocomplete tabbed.
## Deps
- [`shuma-core`](../shuma-core/README.md)
@@ -0,0 +1,9 @@
# shuma-line
> Readline (edit · completion · highlight) of [shuma](../../README.md).
Own implementation (not `rustyline`) sharing the ropebuffer with [`text-editor`](../../../llimphi/widgets/text-editor/README.md). Live syntax highlight, tabbed autocomplete.
## Deps
- [`shuma-core`](../shuma-core/README.md)
@@ -0,0 +1,347 @@
//! Parser ANSI mínimo para la salida en streaming de los subprocesos.
//!
//! Cubre lo que un pipe non-TTY recibe en el mundo real:
//!
//! - **SGR** (Select Graphic Rendition): `\x1b[<n>(;<m>)*m`. Colores
//! 16+8+8 (foreground/background 0..7, brillantes 60..67), atributos
//! (bold/dim/italic/underline/reverse), reset (`\x1b[m` o `\x1b[0m`).
//! - **CR** (`\r`): "vuelve al inicio de la línea actual". Las
//! herramientas que emiten progreso (cargo, claude, docker pull…)
//! reescriben la misma línea con `\r<nuevo contenido>`. El parser
//! colapsa lo anterior y emite sólo el último estado de la línea.
//!
//! NO cubre (por ahora):
//!
//! - Movimientos de cursor (`\x1b[H`, `\x1b[A/B/C/D`, etc.) — son
//! propios de aplicaciones fullscreen tipo vim/htop, que necesitan
//! PTY (los pipes no las recibirán). Se ignoran al ver `\x1b[` con
//! un terminator que no es `m`.
//! - Borrado de pantalla / línea (`\x1b[J`, `\x1b[K`).
//! - OSC (títulos, hyperlinks).
//!
//! Esas funcionalidades caen en la Fase B (PTY + vt100 emulator).
use serde::{Deserialize, Serialize};
/// Atributos de estilo de un span. Todos son opcionales para que el
/// frontend sepa "no toques este aspecto" vs "fíjalo a este valor".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct AnsiStyle {
pub fg: Option<AnsiColor>,
pub bg: Option<AnsiColor>,
pub bold: bool,
pub dim: bool,
pub italic: bool,
pub underline: bool,
pub reverse: bool,
}
impl AnsiStyle {
/// `true` si todo está vacío — el frontend puede usarlo como
/// "saltar a renderizado plano".
pub fn is_plain(&self) -> bool {
self.fg.is_none()
&& self.bg.is_none()
&& !self.bold
&& !self.dim
&& !self.italic
&& !self.underline
&& !self.reverse
}
}
/// Los 16 colores ANSI estándar (8 normales + 8 brillantes). Los
/// frontends los mapean a sus propios valores HSL/Hex.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnsiColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
BrightBlack,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
}
impl AnsiColor {
fn from_fg_code(c: u32) -> Option<Self> {
match c {
30 => Some(Self::Black),
31 => Some(Self::Red),
32 => Some(Self::Green),
33 => Some(Self::Yellow),
34 => Some(Self::Blue),
35 => Some(Self::Magenta),
36 => Some(Self::Cyan),
37 => Some(Self::White),
90 => Some(Self::BrightBlack),
91 => Some(Self::BrightRed),
92 => Some(Self::BrightGreen),
93 => Some(Self::BrightYellow),
94 => Some(Self::BrightBlue),
95 => Some(Self::BrightMagenta),
96 => Some(Self::BrightCyan),
97 => Some(Self::BrightWhite),
_ => None,
}
}
fn from_bg_code(c: u32) -> Option<Self> {
Self::from_fg_code(c.saturating_sub(10))
}
}
/// Un trozo de texto con un estilo.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnsiSpan {
pub text: String,
pub style: AnsiStyle,
}
/// Parsea una línea de texto que puede contener secuencias ANSI y la
/// descompone en spans con su estilo.
///
/// Tratamiento de `\r`:
///
/// 1. Acumulamos spans en un buffer "lineal" (como si fuera una línea
/// de terminal).
/// 2. Al ver `\r`, "rebobinamos" el cursor al inicio de la línea — el
/// siguiente texto **sobreescribe** los chars previos columna a
/// columna; lo que sobre del estado anterior (si es más largo) se
/// conserva al final.
///
/// Devuelve los spans finales (después de aplicar todos los `\r`).
pub fn parse_ansi_line(input: &str) -> Vec<AnsiSpan> {
// Línea conceptual: un Vec<(char, AnsiStyle)>. Tras todos los `\r`,
// colapsamos en spans contiguos por estilo.
let mut chars: Vec<(char, AnsiStyle)> = Vec::new();
let mut col: usize = 0;
let mut style = AnsiStyle::default();
let mut it = input.chars().peekable();
while let Some(c) = it.next() {
if c == '\x1b' && it.peek() == Some(&'[') {
it.next(); // consume '['
// Leer dígitos y `;` hasta el terminador (letra ASCII).
let mut params = String::new();
let mut terminator = None;
for nc in it.by_ref() {
if nc.is_ascii_alphabetic() {
terminator = Some(nc);
break;
}
params.push(nc);
}
if terminator == Some('m') {
apply_sgr(&mut style, &params);
}
// Otros terminadores se ignoran — son cursor movement /
// erase / etc., que en streaming pipe sin PTY no aplican.
continue;
}
if c == '\r' {
col = 0;
continue;
}
if c == '\n' {
// Una línea no debería traer `\n` (el shell entrega un
// string por línea), pero por robustez lo tratamos como
// separador: cortamos aquí.
break;
}
if col < chars.len() {
chars[col] = (c, style);
} else {
chars.push((c, style));
}
col += 1;
}
// Colapsar `chars` en spans por estilo contiguo.
let mut out: Vec<AnsiSpan> = Vec::new();
let mut cur: Option<(String, AnsiStyle)> = None;
for (c, s) in chars {
match &mut cur {
Some((text, st)) if *st == s => text.push(c),
_ => {
if let Some((text, st)) = cur.take() {
out.push(AnsiSpan { text, style: st });
}
let mut t = String::new();
t.push(c);
cur = Some((t, s));
}
}
}
if let Some((text, style)) = cur {
out.push(AnsiSpan { text, style });
}
out
}
/// Devuelve el texto plano (sin estilos) de una línea con secuencias
/// ANSI. Útil para historial / búsqueda fuzzy / persistir sin colores.
pub fn strip_ansi(input: &str) -> String {
parse_ansi_line(input)
.into_iter()
.map(|s| s.text)
.collect::<Vec<_>>()
.join("")
}
fn apply_sgr(style: &mut AnsiStyle, params: &str) {
let nums: Vec<u32> = if params.is_empty() {
vec![0]
} else {
params
.split(';')
.map(|p| p.parse::<u32>().unwrap_or(0))
.collect()
};
let mut i = 0;
while i < nums.len() {
let n = nums[i];
match n {
0 => *style = AnsiStyle::default(),
1 => style.bold = true,
2 => style.dim = true,
3 => style.italic = true,
4 => style.underline = true,
7 => style.reverse = true,
22 => {
style.bold = false;
style.dim = false;
}
23 => style.italic = false,
24 => style.underline = false,
27 => style.reverse = false,
30..=37 | 90..=97 => style.fg = AnsiColor::from_fg_code(n),
39 => style.fg = None,
40..=47 | 100..=107 => style.bg = AnsiColor::from_bg_code(n),
49 => style.bg = None,
38 => {
// 256-color o 24-bit. `38;5;<idx>` o `38;2;r;g;b`. Lo
// saltamos por ahora (no es lo más común); reconocemos
// el tamaño del subparámetro para no descarrilar.
match nums.get(i + 1) {
Some(5) => i += 2,
Some(2) => i += 4,
_ => {}
}
}
48 => match nums.get(i + 1) {
Some(5) => i += 2,
Some(2) => i += 4,
_ => {}
},
_ => {}
}
i += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plain(s: &str) -> Vec<AnsiSpan> {
vec![AnsiSpan { text: s.to_string(), style: AnsiStyle::default() }]
}
#[test]
fn empty_input_yields_no_spans() {
assert!(parse_ansi_line("").is_empty());
}
#[test]
fn text_without_escapes_is_one_plain_span() {
assert_eq!(parse_ansi_line("hola mundo"), plain("hola mundo"));
}
#[test]
fn red_text_picks_up_fg() {
let spans = parse_ansi_line("\x1b[31mROJO\x1b[0m fin");
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].text, "ROJO");
assert_eq!(spans[0].style.fg, Some(AnsiColor::Red));
assert_eq!(spans[1].text, " fin");
assert!(spans[1].style.is_plain());
}
#[test]
fn bold_underline_combo() {
let spans = parse_ansi_line("\x1b[1;4mTITULO\x1b[0m");
assert_eq!(spans.len(), 1);
assert!(spans[0].style.bold);
assert!(spans[0].style.underline);
}
#[test]
fn bg_color_is_offset_from_fg() {
let spans = parse_ansi_line("\x1b[44m sobre azul \x1b[0m");
assert_eq!(spans[0].style.bg, Some(AnsiColor::Blue));
}
#[test]
fn bright_colors_high_range() {
let spans = parse_ansi_line("\x1b[91mbrillante\x1b[0m");
assert_eq!(spans[0].style.fg, Some(AnsiColor::BrightRed));
}
#[test]
fn reset_at_end_clears_style() {
let spans = parse_ansi_line("\x1b[33mwarn:\x1b[0m algo");
assert_eq!(spans[0].style.fg, Some(AnsiColor::Yellow));
assert!(spans[1].style.is_plain());
}
#[test]
fn cr_overwrites_previous_chars() {
// Progreso clásico de cargo/claude/docker.
let spans = parse_ansi_line("12% [### ]\r50% [##### ]");
let text: String = spans.iter().map(|s| s.text.as_str()).collect();
assert!(text.starts_with("50% [#####"));
// El final de la primera línea (`]`) queda detrás del segundo,
// que es más corto en este test artificial — comprueba que la
// segunda escritura sobreescribe columna a columna.
assert_eq!(strip_ansi("12% [### ]\r50% [##### ]"), "50% [##### ]");
}
#[test]
fn cr_keeps_trailing_chars_when_overwrite_is_shorter() {
// Si el segundo "estado" es más corto que el primero, los
// chars del final del primero siguen visibles — exactamente lo
// que hace un terminal real.
let stripped = strip_ansi("...........\rABC");
assert_eq!(stripped, "ABC........");
}
#[test]
fn strip_ansi_drops_all_sgr() {
assert_eq!(strip_ansi("\x1b[31mrojo\x1b[0m"), "rojo");
assert_eq!(strip_ansi("texto plano"), "texto plano");
}
#[test]
fn unknown_escape_terminator_is_skipped_gracefully() {
// `\x1b[2K` (clear line) no es SGR; lo descartamos sin caer.
let s = parse_ansi_line("\x1b[2Kdespués");
assert_eq!(strip_ansi("\x1b[2Kdespués"), "después");
assert!(s.iter().all(|sp| sp.style.is_plain()));
}
#[test]
fn truecolor_sequences_dont_corrupt_subsequent_parsing() {
// `\x1b[38;2;255;128;0m` — 24-bit color. Lo saltamos pero no
// debemos rompernos en lo que viene después.
let s = parse_ansi_line("\x1b[38;2;255;128;0mhola\x1b[0m mundo");
let text: String = s.iter().map(|sp| sp.text.as_str()).collect();
assert_eq!(text, "hola mundo");
}
}
@@ -0,0 +1,462 @@
//! Autocompletado — sugerencias inteligentes según la posición del cursor.
//!
//! El motor decide *qué* se está escribiendo (un comando, un flag o una
//! ruta) mirando la estructura de la línea, y delega la búsqueda de
//! candidatos concretos en una [`CompletionSource`] que el frontend
//! provee (escaneo del `PATH`, del sistema de archivos, etc.).
use serde::{Deserialize, Serialize};
use crate::dialect::Dialect;
use crate::lexer::tokenize;
use crate::token::TokenKind;
/// Origen de candidatos concretos — lo implementa el frontend, que sí
/// conoce el sistema (el `PATH`, el disco). El motor de `shuma-line` se
/// mantiene agnóstico.
pub trait CompletionSource {
/// Nombres de comandos disponibles (típicamente, escaneo del `PATH`).
fn commands(&self) -> Vec<String>;
/// Rutas de archivo que empiezan con `prefix`.
fn paths(&self, prefix: &str) -> Vec<String>;
/// Banderas conocidas para `command`. Por defecto delega en la tabla
/// estática [`flag_hints`] (que cubre los binarios más usados). El
/// frontend puede sobreescribir el método para mergear con un DB
/// personalizado (p. ej. `~/.config/shuma/completions/<cmd>.toml`).
fn flags(&self, command: &str) -> Vec<String> {
flag_hints(command).iter().map(|s| s.to_string()).collect()
}
}
/// Qué clase de cosa se está completando.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompletionKind {
/// El nombre de un comando.
Command,
/// Una opción de un comando.
Flag,
/// Una ruta del sistema de archivos.
Path,
}
/// El resultado de un intento de autocompletado.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Completion {
pub kind: CompletionKind,
/// Candidatos, ordenados y sin repetir.
pub candidates: Vec<String>,
/// Inicio del rango de bytes a reemplazar al aceptar un candidato.
pub replace_start: usize,
/// Fin del rango de bytes a reemplazar.
pub replace_end: usize,
}
impl Completion {
/// `true` si no hay ningún candidato.
pub fn is_empty(&self) -> bool {
self.candidates.is_empty()
}
}
/// Flags universales — `--help` y `-h` los reconoce casi todo binario
/// POSIX/GNU/clap-style. Se agregan siempre al final de las sugerencias.
const UNIVERSAL_FLAGS: &[&str] = &["--help", "-h"];
/// Pistas de flags por comando — diccionario *static* de los comandos
/// más usados en una shell de desarrollo. La fuente del frontend puede
/// extenderlo con un DB cargado en runtime (p. ej. desde
/// `~/.config/shuma/completions/`).
pub fn flag_hints(command: &str) -> &'static [&'static str] {
match command {
// --- coreutils ---
"ls" => &[
"-l", "-a", "-la", "-lh", "-A", "-R", "-r", "-t", "-S", "-d", "-1", "-F",
"--all", "--almost-all", "--color", "--color=always", "--color=auto",
"--color=never", "--human-readable", "--group-directories-first",
"--sort=time", "--sort=size", "--sort=name", "--reverse",
],
"cat" => &["-A", "-b", "-E", "-n", "-s", "-T", "-v", "--number", "--show-ends"],
"grep" => &[
"-i", "-v", "-r", "-R", "-n", "-E", "-F", "-l", "-c", "-w", "-x", "-o",
"-A", "-B", "-C", "--color", "--color=always", "--include", "--exclude",
"--exclude-dir", "--binary-files=without-match",
],
"sed" => &["-e", "-f", "-i", "-n", "-r", "-E", "--in-place"],
"awk" => &["-F", "-v", "-f", "-W"],
"find" => &[
"-name", "-iname", "-type", "-mtime", "-newer", "-size", "-maxdepth",
"-mindepth", "-prune", "-print", "-exec", "-delete", "-empty", "-not",
"-and", "-or", "-path", "-regex",
],
"rm" => &["-r", "-f", "-rf", "-i", "-v", "-d", "--recursive", "--force", "--interactive"],
"cp" => &["-r", "-a", "-v", "-p", "-u", "-f", "-i", "-n", "--recursive", "--archive"],
"mv" => &["-f", "-i", "-n", "-v", "--no-clobber"],
"mkdir" => &["-p", "-v", "-m", "--parents"],
"head" => &["-n", "-c", "-q", "-v"],
"tail" => &["-n", "-c", "-f", "-F", "-q", "-v", "--follow", "--retry"],
"wc" => &["-c", "-l", "-w", "-m", "-L"],
"sort" => &["-n", "-r", "-u", "-k", "-t", "-f", "-h", "-V", "--unique", "--reverse"],
"uniq" => &["-c", "-d", "-u", "-i", "-f", "-s"],
"du" => &["-h", "-s", "-a", "-c", "-d", "-x", "--max-depth", "--summarize"],
"df" => &["-h", "-T", "-i", "-x", "--type", "--human-readable"],
"ps" => &["-e", "-f", "-aux", "-u", "-o", "-p", "--ppid"],
"kill" => &["-9", "-15", "-STOP", "-CONT", "-HUP", "-INT", "-l", "-s"],
"tar" => &[
"-c", "-x", "-z", "-j", "-J", "-v", "-f", "-t", "-C",
"-czf", "-xzf", "-tzf", "-cjf", "-xjf",
"--create", "--extract", "--list", "--gzip", "--bzip2", "--xz",
],
"curl" => &[
"-s", "-S", "-L", "-o", "-O", "-X", "-H", "-d", "-D", "-i", "-I", "-v", "-f", "-k",
"--silent", "--show-error", "--location", "--output", "--remote-name",
"--request", "--header", "--data", "--insecure", "--fail",
],
"wget" => &[
"-q", "-O", "-c", "-r", "-l", "--quiet", "--output-document", "--continue",
"--recursive", "--no-check-certificate",
],
"ssh" => &["-i", "-p", "-l", "-L", "-R", "-D", "-N", "-T", "-X", "-Y", "-A", "-J", "-J"],
"scp" => &["-r", "-P", "-i", "-p", "-q", "-C"],
"rsync" => &[
"-a", "-v", "-z", "-h", "-r", "-n", "--archive", "--verbose", "--compress",
"--dry-run", "--delete", "--exclude", "--progress",
],
// --- cargo / rust ---
"cargo" => &[
"--release", "--workspace", "--all-features", "--no-default-features",
"--features", "-p", "--package", "--bin", "--bins", "--example", "--examples",
"--lib", "--test", "--tests", "--bench", "--benches", "--target", "--target-dir",
"--manifest-path", "--frozen", "--locked", "--offline", "-v", "-vv", "--quiet",
"--color=always", "--message-format=json",
],
"rustup" => &["--version", "--verbose", "--quiet", "--toolchain"],
"rustc" => &[
"--edition", "--crate-type", "--emit", "-O", "-g", "-C", "-Z", "--target",
"-L", "-l", "--cfg", "--print",
],
// --- git ---
"git" => &["-C", "-c", "-p", "--paginate", "--no-pager", "--version", "--git-dir", "--work-tree"],
// --- contenedores / k8s ---
"docker" => &[
"-d", "-it", "--rm", "--name", "--restart", "-p", "-e", "-v",
"--network", "--volume", "--env", "--env-file", "--cpus", "--memory",
],
"podman" => &["-d", "-it", "--rm", "--name", "-p", "-e", "-v", "--network", "--pod"],
"kubectl" => &[
"-n", "--namespace", "-o", "--output", "-w", "--watch", "-f", "--filename",
"--context", "-l", "--selector", "--all-namespaces", "-A",
],
"systemctl" => &[
"--user", "--system", "--now", "--no-pager", "--full", "-l", "-r", "-a",
"--state", "--type", "--failed",
],
"journalctl" => &[
"-u", "-f", "-r", "-n", "-k", "-b", "--since", "--until", "--user-unit",
"--no-pager", "-p", "--priority",
],
// --- desarrollo ---
"make" => &["-j", "-C", "-f", "-n", "-B", "-s", "--jobs", "--always-make"],
"ninja" => &["-j", "-C", "-n", "-v", "-t"],
"python" => &["-c", "-m", "-u", "-V", "-O", "-OO", "-i", "--version"],
"python3" => &["-c", "-m", "-u", "-V", "-O", "-OO", "-i", "--version"],
"node" => &["-e", "-v", "-p", "--inspect", "--inspect-brk", "--version"],
"deno" => &["run", "test", "fmt", "lint", "-A", "--allow-net", "--allow-read", "--allow-write"],
"go" => &["build", "run", "test", "mod", "get", "fmt", "vet", "-race", "-tags"],
// --- vim / editores ---
"vim" => &["-c", "-O", "-o", "-p", "-R", "-d", "-u", "-N", "+"],
"nvim" => &["-c", "-O", "-o", "-p", "-R", "-d", "-u", "-N", "+", "--headless"],
"hx" => &["--tutor", "--health", "--config", "-V", "--version"],
"code" => &["-r", "-n", "-g", "-d", "--reuse-window", "--new-window", "--goto", "--diff"],
// --- proceso / debug ---
"strace" => &["-e", "-f", "-o", "-p", "-c", "-y", "-tt", "-T", "-s"],
"ltrace" => &["-e", "-f", "-o", "-p", "-c"],
"gdb" => &["-q", "-c", "--args", "-ex", "-batch", "--tui"],
"perf" => &["record", "report", "stat", "top", "-F", "-g", "-p", "-e"],
// --- shuma / brahman ---
"shuma" => &[
"workspace", "run", "pipeline", "discern", "capabilities",
"--socket", "--json", "--verbose",
],
_ => &[],
}
}
/// Extiende cualquier lista de flags con los universales (`--help`/`-h`)
/// si todavía no están presentes. Lo usa el motor cuando filtra los
/// candidatos para `prefix`.
fn extend_with_universal(mut flags: Vec<String>) -> Vec<String> {
for u in UNIVERSAL_FLAGS {
let s = (*u).to_string();
if !flags.iter().any(|f| f == &s) {
flags.push(s);
}
}
flags
}
/// Calcula el autocompletado para `line` con el cursor en `cursor`
/// (offset de byte). Nunca entra en pánico si `cursor` cae en mitad de
/// un carácter: se ajusta al límite válido anterior.
pub fn complete(
line: &str,
cursor: usize,
dialect: Dialect,
source: &dyn CompletionSource,
) -> Completion {
let mut cursor = cursor.min(line.len());
while cursor > 0 && !line.is_char_boundary(cursor) {
cursor -= 1;
}
let tokens = tokenize(line, dialect);
// Token que se está editando: aquel cuyo contenido llega al cursor.
let word_token = tokens
.iter()
.find(|t| t.start < cursor && cursor <= t.end && t.kind.is_content());
let (prefix, repl_start, repl_end) = match word_token {
Some(t) => (&line[t.start..cursor], t.start, cursor),
None => ("", cursor, cursor),
};
let word_start = repl_start;
// Recorre los tokens previos a la palabra para saber si la etapa
// actual ya tiene comando (→ estamos en posición de argumento).
let mut stage_command: Option<String> = None;
let mut has_command = false;
for t in &tokens {
if t.end > word_start {
break;
}
match t.kind {
TokenKind::Pipe | TokenKind::Operator => {
stage_command = None;
has_command = false;
}
TokenKind::Command => {
stage_command = Some(t.text.clone());
has_command = true;
}
_ => {}
}
}
let (kind, mut candidates, repl_start_final) = if !has_command {
let cs = source
.commands()
.into_iter()
.filter(|c| c.starts_with(prefix))
.collect();
(CompletionKind::Command, cs, repl_start)
} else if prefix.starts_with('-') {
// Caso `--foo=<...>`: tras `=`, el cursor está completando el
// *valor* del flag, no otro flag. Lo más útil hoy es path
// completion (cubre `--config=`, `--output=`, etc.). En el futuro
// podríamos consultar al source por tipos de valor por flag.
if let Some(eq) = prefix.find('=') {
let value_prefix = &prefix[eq + 1..];
let cs = source.paths(value_prefix);
(CompletionKind::Path, cs, repl_start + eq + 1)
} else {
let hints = stage_command
.as_deref()
.map(|c| source.flags(c))
.unwrap_or_default();
let cs = extend_with_universal(hints)
.into_iter()
.filter(|f| f.starts_with(prefix))
.collect();
(CompletionKind::Flag, cs, repl_start)
}
} else {
(CompletionKind::Path, source.paths(prefix), repl_start)
};
candidates.sort();
candidates.dedup();
candidates.truncate(200);
Completion {
kind,
candidates,
replace_start: repl_start_final,
replace_end: repl_end,
}
}
/// Fuente de candidatos con listas fijas — útil para tests y para un
/// arranque sin escaneo del sistema.
#[derive(Debug, Clone, Default)]
pub struct StaticSource {
pub commands: Vec<String>,
pub paths: Vec<String>,
}
impl CompletionSource for StaticSource {
fn commands(&self) -> Vec<String> {
self.commands.clone()
}
fn paths(&self, prefix: &str) -> Vec<String> {
self.paths
.iter()
.filter(|p| p.starts_with(prefix))
.cloned()
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn source() -> StaticSource {
StaticSource {
commands: vec![
"ls".into(),
"lsblk".into(),
"grep".into(),
"git".into(),
"cargo".into(),
],
paths: vec![
"Cargo.toml".into(),
"Cargo.lock".into(),
"src/".into(),
"README.md".into(),
],
}
}
fn complete_at(line: &str, cursor: usize) -> Completion {
complete(line, cursor, Dialect::Bash, &source())
}
#[test]
fn completes_command_names_from_prefix() {
let c = complete_at("ls", 2);
assert_eq!(c.kind, CompletionKind::Command);
assert_eq!(c.candidates, vec!["ls", "lsblk"]);
assert_eq!((c.replace_start, c.replace_end), (0, 2));
}
#[test]
fn completes_flags_for_the_stage_command() {
let c = complete_at("ls -l", 5);
assert_eq!(c.kind, CompletionKind::Flag);
assert!(c.candidates.contains(&"-l".to_string()));
assert!(c.candidates.contains(&"-la".to_string()));
assert!(c.candidates.iter().all(|f| f.starts_with("-l")));
}
#[test]
fn completes_paths_in_argument_position() {
let c = complete_at("cat Cargo", 9);
assert_eq!(c.kind, CompletionKind::Path);
assert_eq!(c.candidates, vec!["Cargo.lock", "Cargo.toml"]);
}
#[test]
fn completes_command_after_a_pipe() {
// Tras `| g`, se completa un comando nuevo, no una ruta.
let c = complete_at("cat f | g", 9);
assert_eq!(c.kind, CompletionKind::Command);
assert_eq!(c.candidates, vec!["git", "grep"]);
}
#[test]
fn empty_line_offers_all_commands() {
let c = complete_at("", 0);
assert_eq!(c.kind, CompletionKind::Command);
assert_eq!(c.candidates.len(), 5);
}
#[test]
fn completing_in_whitespace_starts_a_fresh_word() {
// Cursor tras `cargo ` → posición de argumento, prefijo vacío.
let c = complete_at("cargo ", 6);
assert_eq!(c.kind, CompletionKind::Path);
assert_eq!((c.replace_start, c.replace_end), (6, 6));
}
#[test]
fn flag_completion_knows_the_command() {
let c = complete_at("cargo --re", 10);
assert_eq!(c.kind, CompletionKind::Flag);
assert_eq!(c.candidates, vec!["--release"]);
}
#[test]
fn cursor_past_end_is_clamped() {
let c = complete_at("gi", 999);
assert_eq!(c.candidates, vec!["git"]);
}
#[test]
fn universal_help_flags_always_suggested() {
// Comando sin entrada en la tabla estática igualmente recibe -h/--help.
let c = complete_at("foobar -", 8);
assert_eq!(c.kind, CompletionKind::Flag);
assert!(c.candidates.contains(&"-h".to_string()));
assert!(c.candidates.contains(&"--help".to_string()));
}
#[test]
fn equals_in_flag_switches_to_path_completion() {
// `cargo --manifest-path=Car` debe completar paths a partir de `Car`,
// reemplazando sólo el sufijo (no el flag completo).
let c = complete_at("cargo --manifest-path=Car", 25);
assert_eq!(c.kind, CompletionKind::Path);
assert_eq!(c.candidates, vec!["Cargo.lock", "Cargo.toml"]);
// El reemplazo arranca en la posición justo después del `=`.
let s = "cargo --manifest-path=";
assert_eq!(c.replace_start, s.len());
assert_eq!(c.replace_end, 25);
}
#[test]
fn source_can_override_flag_db() {
// Una fuente custom puede ampliar el catálogo más allá de la
// tabla estática (lo aprovecha el shell para cargar
// ~/.config/shuma/completions/<cmd>.toml).
#[derive(Default)]
struct CustomSource {
commands: Vec<String>,
}
impl CompletionSource for CustomSource {
fn commands(&self) -> Vec<String> {
self.commands.clone()
}
fn paths(&self, _: &str) -> Vec<String> {
Vec::new()
}
fn flags(&self, command: &str) -> Vec<String> {
if command == "mytool" {
vec!["--mytool-only".into(), "--verbose".into()]
} else {
flag_hints(command).iter().map(|s| s.to_string()).collect()
}
}
}
let s = CustomSource { commands: vec!["mytool".into()] };
let c = complete("mytool --m", 10, Dialect::Bash, &s);
assert_eq!(c.kind, CompletionKind::Flag);
assert!(c.candidates.contains(&"--mytool-only".to_string()));
}
#[test]
fn after_pipe_help_flag_works_for_new_stage_command() {
// `cargo build | grep -` → flags de grep, no de cargo.
let c = complete_at("cargo build | grep -", 20);
assert_eq!(c.kind, CompletionKind::Flag);
// grep tiene -i; cargo no.
assert!(c.candidates.iter().any(|f| f == "-i"));
// El universal sigue ahí.
assert!(c.candidates.contains(&"-h".to_string()));
}
}
@@ -0,0 +1,309 @@
//! Detector de "input pendiente" — cuándo Enter debe seguir escribiendo
//! en vez de submit-ear.
//!
//! Convenciones bash que cubrimos:
//!
//! - **Comilla simple sin cerrar**: `echo 'hola` → continuación.
//! - **Comilla doble sin cerrar**: `echo "hola` → continuación.
//! - **Paréntesis sin cerrar**: subshell `(...)`, command substitution
//! `$(...)`. Anidan.
//! - **Heredoc abierto**: `cat <<EOF` (con o sin `-`, con tag entre
//! comillas o sin ellas). El heredoc se cierra con una línea que sólo
//! contenga el tag (estrip de tabs si `<<-`).
//! - **`\` al final de línea** (line continuation clásica).
//! - **Operador pendiente al final**: `cmd |`, `cmd &&`, `cmd ||`. Sólo
//! cuenta si NO está dentro de una cadena.
//!
//! NO cubrimos (a propósito):
//!
//! - `{...}` y `[...]` — en bash son comandos (`test`), expansión de
//! llaves, o brace-groups; detectar correctamente cuándo "abren"
//! requiere casi un parser completo. Si el usuario los escribe en
//! varias líneas, puede usar `\` al final o `<<EOF` heredoc.
//!
//! El detector es deliberadamente *barato* (un pase lineal por los
//! bytes, O(n)) — se llama en cada Enter del frontend.
/// `true` si `text` tiene una construcción shell *abierta* que esperaba
/// más input. El frontend lo usa para decidir entre insertar `\n` (en
/// curso) o ejecutar (cerrado).
pub fn needs_continuation(text: &str) -> bool {
let mut single_q = false;
let mut double_q = false;
let mut depth_paren: i32 = 0;
let mut heredoc_tag: Option<String> = None;
let mut heredoc_strip = false;
// El último token shell relevante para detectar operadores
// pendientes al final: pipe, &&, ||. Se resetea cuando vemos
// contenido no-vacío después.
let mut trailing_op: Option<TrailingOp> = None;
// `\` justo antes de `\n` significa continuación pero también:
// dentro de comillas simples, `\` es literal. Lo manejamos al final
// mirando el último byte no-blanco del texto entero.
let lines: Vec<&str> = text.split('\n').collect();
for line in &lines {
// Si estamos dentro de un heredoc body, sólo importa si esta
// línea sola es el tag de cierre (con strip de tabs si <<-).
if let Some(tag) = heredoc_tag.as_ref() {
let candidate = if heredoc_strip {
line.trim_start_matches('\t')
} else {
*line
};
if candidate == tag {
heredoc_tag = None;
}
continue;
}
let bytes = line.as_bytes();
let mut i = 0;
let mut pending_heredoc: Option<(String, bool)> = None;
let mut prev_backslash = false;
// Por línea, resetemos el `trailing_op` y lo recalculamos.
trailing_op = None;
while i < bytes.len() {
let c = bytes[i];
if prev_backslash {
prev_backslash = false;
// Backslash escapó este caracter — no es operador ni quote.
i += 1;
continue;
}
if single_q {
if c == b'\'' {
single_q = false;
}
i += 1;
continue;
}
if double_q {
if c == b'\\' && i + 1 < bytes.len() {
// En doble comilla, \" y \\ y \$ son escapes.
i += 2;
continue;
}
if c == b'"' {
double_q = false;
}
i += 1;
continue;
}
match c {
b'#' => break, // comentario hasta fin de línea
b'\\' => {
prev_backslash = true;
trailing_op = None;
}
b'\'' => {
single_q = true;
trailing_op = None;
}
b'"' => {
double_q = true;
trailing_op = None;
}
b'(' => {
depth_paren += 1;
trailing_op = None;
}
b')' => {
if depth_paren > 0 {
depth_paren -= 1;
}
trailing_op = None;
}
b'<' if i + 1 < bytes.len() && bytes[i + 1] == b'<' => {
let mut start = i + 2;
let strip = bytes.get(start) == Some(&b'-');
if strip {
start += 1;
}
while start < bytes.len() && (bytes[start] == b' ' || bytes[start] == b'\t') {
start += 1;
}
// Tag entre comillas → literal (sin expansión); sin
// comillas → identificador.
let (tag, end) = if let Some(&q) = bytes.get(start) {
if q == b'\'' || q == b'"' {
let mut end = start + 1;
while end < bytes.len() && bytes[end] != q {
end += 1;
}
(
line[start + 1..end.min(line.len())].to_string(),
(end + 1).min(line.len()),
)
} else {
let mut end = start;
while end < bytes.len()
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
{
end += 1;
}
(line[start..end].to_string(), end)
}
} else {
(String::new(), start)
};
if !tag.is_empty() {
pending_heredoc = Some((tag, strip));
}
i = end;
trailing_op = None;
continue;
}
b'|' => {
// `||` cuenta como `Or`, `|` como `Pipe`. Avanzamos
// dos bytes en el primer caso para no clasificarlo dos
// veces.
if bytes.get(i + 1) == Some(&b'|') {
trailing_op = Some(TrailingOp::Or);
i += 2;
continue;
}
trailing_op = Some(TrailingOp::Pipe);
}
b'&' => {
if bytes.get(i + 1) == Some(&b'&') {
trailing_op = Some(TrailingOp::And);
i += 2;
continue;
}
// `&` solo es background — el shell ya lo trata
// antes de ExecSpec; no abre nada.
trailing_op = None;
}
b' ' | b'\t' => {
// El whitespace no cancela el trailing_op (queremos
// que `cmd | ` siga siendo pipe pendiente).
}
_ => {
trailing_op = None;
}
}
i += 1;
}
if let Some((tag, strip)) = pending_heredoc {
heredoc_tag = Some(tag);
heredoc_strip = strip;
}
// `\` al final de la línea (sin terminar) = continuación.
// `prev_backslash` quedó `true` si el último char era `\` sin
// procesar; lo metemos como un trailing_op especial.
if prev_backslash {
trailing_op = Some(TrailingOp::Backslash);
}
}
single_q
|| double_q
|| depth_paren > 0
|| heredoc_tag.is_some()
|| trailing_op.is_some()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TrailingOp {
Pipe,
And,
Or,
Backslash,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_complete_line_does_not_need_continuation() {
assert!(!needs_continuation("echo hola"));
assert!(!needs_continuation(""));
assert!(!needs_continuation("ls -la"));
}
#[test]
fn unclosed_single_quote_needs_continuation() {
assert!(needs_continuation("echo 'hola"));
// Cerrada en línea siguiente — sigue abierta hasta ver `'`.
assert!(needs_continuation("echo 'hola\nmundo"));
// Cerrada → no continúa.
assert!(!needs_continuation("echo 'hola\nmundo'"));
}
#[test]
fn unclosed_double_quote_needs_continuation() {
assert!(needs_continuation("echo \"hola"));
assert!(!needs_continuation("echo \"hola\""));
}
#[test]
fn quoted_pipe_does_not_count() {
// `echo "|"` está completo — el `|` está dentro de quotes.
assert!(!needs_continuation("echo \"|\""));
assert!(!needs_continuation("echo '|'"));
}
#[test]
fn unbalanced_paren_needs_continuation() {
assert!(needs_continuation("echo $(cat foo"));
assert!(needs_continuation("(echo a"));
// Balanceadas: ok.
assert!(!needs_continuation("echo $(cat foo)"));
assert!(!needs_continuation("(echo a)"));
}
#[test]
fn trailing_pipe_needs_continuation() {
assert!(needs_continuation("cat foo |"));
assert!(needs_continuation("cat foo | "));
// `||` también, como pipe-y.
assert!(needs_continuation("cmd ||"));
assert!(needs_continuation("cmd &&"));
// Pero un `cmd` sólo no.
assert!(!needs_continuation("cmd"));
}
#[test]
fn trailing_backslash_needs_continuation() {
assert!(needs_continuation("cargo build \\"));
// Múltiples líneas con `\` al final cada una.
assert!(needs_continuation("a \\\nb \\"));
// La última línea sin `\` ya está completa.
assert!(!needs_continuation("a \\\nb"));
}
#[test]
fn heredoc_open_needs_continuation_until_tag_seen() {
assert!(needs_continuation("cat <<EOF"));
assert!(needs_continuation("cat <<EOF\ncontenido"));
assert!(!needs_continuation("cat <<EOF\ncontenido\nEOF"));
// Tag entre comillas (sin expansión).
assert!(needs_continuation("cat <<'EOF'\ncontenido"));
assert!(!needs_continuation("cat <<'EOF'\ncontenido\nEOF"));
}
#[test]
fn heredoc_with_dash_strips_tabs_on_close() {
assert!(needs_continuation("cat <<-EOF\ncontenido"));
// El tag de cierre puede llevar tabs adelante con `<<-`.
assert!(!needs_continuation("cat <<-EOF\ncontenido\n\t\tEOF"));
// Sin `<<-`, las tabs antes del tag NO cierran.
assert!(needs_continuation("cat <<EOF\ncontenido\n\tEOF"));
}
#[test]
fn comment_does_not_open_anything() {
// Un `#` empieza un comentario hasta fin de línea.
assert!(!needs_continuation("ls # un comentario \"raro' |"));
}
#[test]
fn single_quote_makes_backslash_literal() {
// Dentro de `'...'` el `\` es literal, así que `'\'` no escapa
// el `'` siguiente — es comilla cerrada + `'` abre otra. Es
// exactamente cómo lo hace bash.
// Test pragmático: una sola apertura sigue abierta.
assert!(needs_continuation("echo '\\"));
}
}
@@ -0,0 +1,762 @@
//! Decorador inteligente del output — encuentra "cosas interactivas"
//! (paths existentes, URLs, referencias `path:line:col`) y emite
//! [`Decoration`]s para que el frontend las pinte con un click handler.
//!
//! Es lo que convierte un `ls` en una lista clickeable o un mensaje de
//! cargo con `--> src/main.rs:42:7` en un link al editor — sin que el
//! comando tenga que cooperar.
//!
//! Diseño:
//!
//! - **Lookup, no parser**: no asumimos el shape del comando (no
//! parseamos "ls -la" vs "ls"). Sólo miramos los tokens del output y
//! probamos el sistema de archivos (stat real, una syscall barata).
//! Funciona para `ls`, `find`, `tree`, `grep -l`, `git status`,
//! incluso para tu output ad-hoc con paths en medio.
//! - **Anclado a cwd del run**: el path relativo se resuelve contra el
//! directorio donde corrió el comando, no contra el shell ahora —
//! crítico para que `:cd` posteriores no rompan las decoraciones de
//! runs viejos.
//! - **No regex**: scanner manual de prefijos URL y delimitadores. Más
//! barato y predecible. Para `path:line:col` usamos un parser simple
//! en el inicio de línea (formato típico de grep/rg/compiladores).
use std::fs::Metadata;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// Una decoración aplicable a un rango de bytes de una línea.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decoration {
/// Inicio del rango (byte offset desde el comienzo de la línea).
pub start: usize,
/// Fin exclusivo del rango.
pub end: usize,
/// Qué representa el rango y qué acción dispara un click.
pub kind: DecorationKind,
}
/// Tipos de decoración que el shell pinta.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DecorationKind {
/// Un path que existe — el frontend lo pinta clickeable y la acción
/// depende de `is_dir`/`is_executable`.
Path {
/// Path absoluto (joined con cwd si era relativo).
abs: PathBuf,
is_dir: bool,
is_executable: bool,
is_symlink: bool,
},
/// Una URL — el frontend la abre con `xdg-open` o equivalente.
Url(String),
/// `path:line[:col]` típico de grep/rg/compiladores. El frontend
/// abre `path` en el editor saltando a `line_no`.
GrepRef {
abs: PathBuf,
line_no: u32,
col: Option<u32>,
},
/// SHA de git (hex 7..40 chars). El frontend sugiere `git show
/// <sha>` en el input.
GitSha(String),
/// Referencia tipo `#1234` — issue/PR de GitHub/GitLab/Gitea. Sin
/// click action porque la url depende del repo; el frontend puede
/// resolverla mediante `git config remote.origin.url` + reglas
/// por host en el futuro. Hoy sólo se pinta destacado.
IssueRef(u32),
/// Run contiguo de caracteres de **box-drawing** Unicode
/// (U+2500..U+257F y U+2580..U+259F). El frontend los renderiza
/// con la fuente monospace + color accent para que los bordes
/// calcen entre filas y se vean como una caja real.
BoxDraw,
}
/// Punto de entrada: detecta decoraciones para una línea. `cwd` se usa
/// para resolver paths relativos. El orden importa porque las primeras
/// reclaman el rango antes que las siguientes:
///
/// 1. **Box-drawing** — son chars no-ASCII contiguos, sólo necesitan
/// detección visual; van primero para que un `─` accidental no se
/// interprete como nada raro después.
/// 2. **URLs** — las más específicas, prefijo único.
/// 3. **GrepRefs** — `path:N[:N]` al inicio de línea (cargo, grep, rg).
/// 4. **GitSha** + **IssueRef** — patrones reconocibles fuera de
/// contextos de paths.
/// 5. **Paths** — generales, captura los tokens restantes.
///
/// Las decoraciones que solapan se descartan (gana la más temprana).
pub fn decorate_line(line: &str, cwd: &Path) -> Vec<Decoration> {
let mut out: Vec<Decoration> = Vec::new();
find_box_draw(line, &mut out);
find_urls(line, &mut out);
find_grep_refs(line, cwd, &mut out);
find_git_shas(line, &mut out);
find_issue_refs(line, &mut out);
find_paths(line, cwd, &mut out);
out.sort_by_key(|d| d.start);
let mut merged: Vec<Decoration> = Vec::with_capacity(out.len());
for d in out {
if let Some(prev) = merged.last() {
if d.start < prev.end {
continue;
}
}
merged.push(d);
}
merged
}
// --- Box-drawing detection ---
/// `true` si `c` pertenece a las áreas Unicode de líneas/bordes que
/// las CLIs modernas (gemini, claude, cargo, etc.) usan para dibujar
/// cajas: `Box Drawing` (U+2500..U+257F) y `Block Elements`
/// (U+2580..U+259F).
fn is_box_draw(c: char) -> bool {
let u = c as u32;
(0x2500..=0x257F).contains(&u) || (0x2580..=0x259F).contains(&u)
}
fn find_box_draw(line: &str, out: &mut Vec<Decoration>) {
let mut start: Option<usize> = None;
for (i, c) in line.char_indices() {
if is_box_draw(c) {
if start.is_none() {
start = Some(i);
}
} else if let Some(s) = start.take() {
out.push(Decoration {
start: s,
end: i,
kind: DecorationKind::BoxDraw,
});
}
}
if let Some(s) = start {
out.push(Decoration {
start: s,
end: line.len(),
kind: DecorationKind::BoxDraw,
});
}
}
// --- Git SHA + Issue ref detection ---
/// SHAs de git: hex (0-9a-f) de 7..40 chars de largo. Para reducir
/// falsos positivos exigimos: rodeado por boundary (inicio/fin de
/// línea, whitespace o puntuación) y al menos un dígito O al menos
/// una letra (puro `aaaaaaa` raramente es un SHA).
fn find_git_shas(line: &str, out: &mut Vec<Decoration>) {
let bytes = line.as_bytes();
let n = bytes.len();
let mut i = 0;
while i < n {
// Buscar inicio de un run hex
if !is_boundary(line, i) {
i += 1;
continue;
}
let start = i;
let mut end = i;
let mut has_digit = false;
let mut has_alpha = false;
while end < n {
let c = bytes[end];
if c.is_ascii_digit() {
has_digit = true;
end += 1;
} else if matches!(c, b'a'..=b'f') {
has_alpha = true;
end += 1;
} else {
break;
}
}
let len = end - start;
if len >= 7 && len <= 40 && has_digit && has_alpha && is_end_boundary(line, end) {
// Evitar solapar con decoraciones previas (URLs, paths,
// etc.) — chequeo barato.
if !overlaps_any(start, end, out) {
out.push(Decoration {
start,
end,
kind: DecorationKind::GitSha(line[start..end].to_string()),
});
}
}
i = end.max(start + 1);
}
}
/// `#NN` típico de issues/PRs en repos. Acepta 1..7 dígitos (millones
/// de issues son raros y ayudan a evitar falsos positivos con hashes
/// numéricos o números de línea).
fn find_issue_refs(line: &str, out: &mut Vec<Decoration>) {
let bytes = line.as_bytes();
let n = bytes.len();
let mut i = 0;
while i < n {
if bytes[i] == b'#' && is_boundary(line, i) {
let start = i;
let mut end = i + 1;
while end < n && bytes[end].is_ascii_digit() {
end += 1;
}
let digits = end - i - 1;
if (1..=7).contains(&digits) && is_end_boundary(line, end) {
if !overlaps_any(start, end, out) {
if let Ok(num) = line[start + 1..end].parse::<u32>() {
out.push(Decoration {
start,
end,
kind: DecorationKind::IssueRef(num),
});
}
}
}
i = end.max(start + 1);
} else {
i += 1;
}
}
}
/// `true` si justo a la izquierda de `pos` hay un carácter no-palabra
/// (o estamos en inicio de línea). Lo usamos en find_git_shas /
/// find_issue_refs para anclar el INICIO del patrón.
fn is_boundary(line: &str, pos: usize) -> bool {
let bytes = line.as_bytes();
if pos == 0 || pos == bytes.len() {
return true;
}
let prev = bytes[pos - 1];
!(prev.is_ascii_alphanumeric() || prev == b'_')
}
/// `true` si justo a la derecha de `pos` hay un carácter no-palabra
/// (o estamos en fin de línea). Ancla el FIN del patrón.
fn is_end_boundary(line: &str, pos: usize) -> bool {
let bytes = line.as_bytes();
if pos >= bytes.len() {
return true;
}
let next = bytes[pos];
!(next.is_ascii_alphanumeric() || next == b'_')
}
// --- URL detection ---
const URL_PREFIXES: &[&str] = &["http://", "https://", "file://", "ftp://", "ssh://"];
fn find_urls(line: &str, out: &mut Vec<Decoration>) {
for prefix in URL_PREFIXES {
let mut search_from = 0;
while let Some(rel) = line[search_from..].find(prefix) {
let start = search_from + rel;
let mut end = start + prefix.len();
let bytes = line.as_bytes();
while end < bytes.len() {
let c = bytes[end];
if c.is_ascii_whitespace()
|| matches!(c, b'<' | b'>' | b'`' | b'"' | b'\'' | b'(' | b')')
{
break;
}
end += 1;
}
// Trim puntuación final típica de prosa: .,;:
while end > start + prefix.len() {
let last = bytes[end - 1];
if matches!(last, b'.' | b',' | b';' | b':' | b']' | b'!' | b'?') {
end -= 1;
} else {
break;
}
}
if end > start + prefix.len() {
out.push(Decoration {
start,
end,
kind: DecorationKind::Url(line[start..end].to_string()),
});
}
search_from = end;
}
}
}
// --- GrepRef detection ---
fn find_grep_refs(line: &str, cwd: &Path, out: &mut Vec<Decoration>) {
// Patrón estándar de grep/rg/compiladores: el path comienza al
// inicio de la línea (eventualmente tras whitespace) y termina en
// el primer `:` que va seguido de dígitos.
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let path_start = i;
// Cargo/rustc emiten `--> path:line:col` o `<path>:<line>:<col>`.
// Saltamos el prefijo `--> ` si está.
if line[i..].starts_with("--> ") {
i += 4;
} else if line[i..].starts_with("error[") || line[i..].starts_with("warning[") {
// Mensajes de cargo en otra forma — los manejamos por la flecha.
return;
}
let path_start = if i > path_start { i } else { path_start };
// Avanzar hasta encontrar un `:<digit>`. Permitimos `:` dentro del
// path si el carácter que sigue no es un dígito.
let mut path_end = path_start;
while path_end < bytes.len() {
let c = bytes[path_end];
if c == b':' {
if path_end + 1 < bytes.len() && bytes[path_end + 1].is_ascii_digit() {
break;
}
}
if c.is_ascii_whitespace() {
return; // espacio antes de `:` → no es grep ref
}
path_end += 1;
}
if path_end >= bytes.len() || path_end == path_start {
return;
}
let path_str = &line[path_start..path_end];
let abs = resolve_path(path_str, cwd);
if !abs.is_some_and(|p| p.exists()) {
return;
}
let abs = resolve_path(path_str, cwd).expect("checked");
// Tras `:`, leer el número de línea.
let mut p = path_end + 1;
let line_start = p;
while p < bytes.len() && bytes[p].is_ascii_digit() {
p += 1;
}
if p == line_start {
return;
}
let line_no: u32 = line[line_start..p].parse().unwrap_or(0);
// Opcional `:<col>`.
let mut col: Option<u32> = None;
let mut end = p;
if p < bytes.len() && bytes[p] == b':' && p + 1 < bytes.len() && bytes[p + 1].is_ascii_digit() {
let col_start = p + 1;
let mut q = col_start;
while q < bytes.len() && bytes[q].is_ascii_digit() {
q += 1;
}
col = line[col_start..q].parse().ok();
end = q;
}
out.push(Decoration {
start: path_start,
end,
kind: DecorationKind::GrepRef { abs, line_no, col },
});
}
// --- Path detection ---
fn find_paths(line: &str, cwd: &Path, out: &mut Vec<Decoration>) {
for (start, end) in tokens_with_ranges(line) {
// Saltar tokens muy cortos (`.`, `..`, números, etc.) — no
// ganamos nada decorándolos y bajamos falsos positivos.
if end - start < 2 {
continue;
}
if overlaps_any(start, end, out) {
continue;
}
let text = &line[start..end];
// Caracteres de puntuación al borde (paréntesis, comas, etc.)
// los recortamos antes de probar el path.
let (trim_start, trim_end) = trim_punctuation(text);
if trim_end <= trim_start {
continue;
}
let path_text = &text[trim_start..trim_end];
let Some(path) = resolve_path(path_text, cwd) else {
continue;
};
let Ok(meta) = std::fs::symlink_metadata(&path) else {
continue;
};
let is_symlink = meta.file_type().is_symlink();
let is_dir = if is_symlink {
std::fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false)
} else {
meta.is_dir()
};
let is_executable = is_exec_unix(&meta);
out.push(Decoration {
start: start + trim_start,
end: start + trim_end,
kind: DecorationKind::Path { abs: path, is_dir, is_executable, is_symlink },
});
}
}
fn resolve_path(token: &str, cwd: &Path) -> Option<PathBuf> {
if token.is_empty() {
return None;
}
let candidate = if token.starts_with('~') {
let home = std::env::var("HOME").ok()?;
if token == "~" {
PathBuf::from(home)
} else if let Some(rest) = token.strip_prefix("~/") {
PathBuf::from(home).join(rest)
} else {
return None;
}
} else if Path::new(token).is_absolute() {
PathBuf::from(token)
} else {
cwd.join(token)
};
Some(candidate)
}
fn tokens_with_ranges(s: &str) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
if start < i {
out.push((start, i));
}
}
out
}
fn trim_punctuation(text: &str) -> (usize, usize) {
let bytes = text.as_bytes();
let mut start = 0;
let mut end = bytes.len();
while start < end
&& matches!(bytes[start], b'(' | b'[' | b'<' | b'`' | b'"' | b'\'' | b',')
{
start += 1;
}
while end > start
&& matches!(
bytes[end - 1],
b')' | b']' | b'>' | b'`' | b'"' | b'\'' | b',' | b'.' | b';' | b':'
)
{
end -= 1;
}
(start, end)
}
fn overlaps_any(start: usize, end: usize, ds: &[Decoration]) -> bool {
ds.iter().any(|d| d.start < end && start < d.end)
}
fn is_exec_unix(meta: &Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
let _ = meta;
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn paths_in(line: &str, cwd: &Path) -> Vec<(usize, usize, PathBuf)> {
decorate_line(line, cwd)
.into_iter()
.filter_map(|d| match d.kind {
DecorationKind::Path { abs, .. } => Some((d.start, d.end, abs)),
_ => None,
})
.collect()
}
#[test]
fn detects_existing_files_in_a_line() {
let d = tempdir().unwrap();
fs::write(d.path().join("alfa.txt"), "x").unwrap();
fs::create_dir(d.path().join("beta")).unwrap();
// Output típico de `ls` separado por whitespace.
let line = "alfa.txt beta no-existe";
let p = paths_in(line, d.path());
let names: Vec<_> = p.iter().map(|(_, _, p)| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
assert!(names.contains(&"alfa.txt".to_string()));
assert!(names.contains(&"beta".to_string()));
assert!(!names.contains(&"no-existe".to_string()));
}
#[test]
fn ranges_point_at_the_token_in_the_line() {
let d = tempdir().unwrap();
fs::write(d.path().join("foo"), "x").unwrap();
let line = "ver foo aquí";
let p = paths_in(line, d.path());
assert_eq!(p.len(), 1);
let (s, e, _) = p[0];
assert_eq!(&line[s..e], "foo");
}
#[test]
fn directory_is_marked_as_dir() {
let d = tempdir().unwrap();
fs::create_dir(d.path().join("subdir")).unwrap();
let line = "subdir";
let dec = decorate_line(line, d.path());
assert_eq!(dec.len(), 1);
match &dec[0].kind {
DecorationKind::Path { is_dir, .. } => assert!(*is_dir),
_ => panic!("expected Path"),
}
}
#[test]
fn executable_bit_detected_on_unix() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let d = tempdir().unwrap();
let p = d.path().join("script");
fs::write(&p, "#!/bin/sh\n").unwrap();
let mut perms = fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&p, perms).unwrap();
let dec = decorate_line("script", d.path());
match &dec[0].kind {
DecorationKind::Path { is_executable, .. } => assert!(*is_executable),
_ => panic!("expected Path"),
}
}
}
#[test]
fn absolute_paths_work_too() {
let d = tempdir().unwrap();
fs::write(d.path().join("x"), "").unwrap();
let abs = d.path().join("x");
let line = format!("toca {}", abs.display());
let p = paths_in(&line, Path::new("/"));
assert_eq!(p.len(), 1);
assert_eq!(p[0].2, abs);
}
#[test]
fn punctuation_around_path_is_trimmed() {
let d = tempdir().unwrap();
fs::write(d.path().join("foo"), "").unwrap();
// En prosa: "abrí (foo)."
let line = "abrí (foo).";
let p = paths_in(line, d.path());
assert_eq!(p.len(), 1);
let (s, e, _) = p[0];
assert_eq!(&line[s..e], "foo");
}
#[test]
fn urls_are_detected() {
let line = "ver https://example.com/x.html, también http://foo.bar";
let dec = decorate_line(line, Path::new("/"));
let urls: Vec<_> = dec
.iter()
.filter_map(|d| match &d.kind {
DecorationKind::Url(u) => Some(u.clone()),
_ => None,
})
.collect();
assert_eq!(urls.len(), 2);
assert!(urls.contains(&"https://example.com/x.html".to_string()));
assert!(urls.contains(&"http://foo.bar".to_string()));
}
#[test]
fn url_strips_trailing_punctuation() {
let line = "abrir https://foo.bar.";
let dec = decorate_line(line, Path::new("/"));
match &dec[0].kind {
DecorationKind::Url(u) => assert_eq!(u, "https://foo.bar"),
_ => panic!("expected Url"),
}
}
#[test]
fn grep_ref_at_start_of_line() {
let d = tempdir().unwrap();
fs::write(d.path().join("src.rs"), "x").unwrap();
let line = "src.rs:42:7: error here";
let dec = decorate_line(line, d.path());
let refs: Vec<_> = dec
.iter()
.filter_map(|d| match &d.kind {
DecorationKind::GrepRef { abs, line_no, col } => Some((abs.clone(), *line_no, *col)),
_ => None,
})
.collect();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].1, 42);
assert_eq!(refs[0].2, Some(7));
}
#[test]
fn grep_ref_without_column() {
let d = tempdir().unwrap();
fs::write(d.path().join("foo.txt"), "x").unwrap();
let line = "foo.txt:10: contenido";
let dec = decorate_line(line, d.path());
match &dec[0].kind {
DecorationKind::GrepRef { line_no, col, .. } => {
assert_eq!(*line_no, 10);
assert_eq!(*col, None);
}
_ => panic!("expected GrepRef"),
}
}
#[test]
fn cargo_arrow_ref_is_picked_up() {
let d = tempdir().unwrap();
fs::create_dir(d.path().join("src")).unwrap();
fs::write(d.path().join("src/main.rs"), "x").unwrap();
// El prefijo ` --> ` con tabs/espacios variables.
let line = " --> src/main.rs:5:9";
let dec = decorate_line(line, d.path());
let r = dec.iter().find_map(|d| match &d.kind {
DecorationKind::GrepRef { line_no, col, .. } => Some((*line_no, *col)),
_ => None,
});
assert_eq!(r, Some((5, Some(9))));
}
#[test]
fn overlaps_dont_double_decorate() {
let d = tempdir().unwrap();
fs::write(d.path().join("src.rs"), "x").unwrap();
// El grep ref ocupa "src.rs:42"; el path "src.rs" solo se
// tragaría aparte si no detectamos solape.
let line = "src.rs:42: x";
let dec = decorate_line(line, d.path());
// Esperamos UNA decoración (la GrepRef cubre el path).
assert_eq!(dec.len(), 1);
assert!(matches!(dec[0].kind, DecorationKind::GrepRef { .. }));
}
#[test]
fn empty_line_yields_nothing() {
assert!(decorate_line("", Path::new("/")).is_empty());
assert!(decorate_line(" ", Path::new("/")).is_empty());
}
#[test]
fn nonexistent_paths_are_not_decorated() {
let d = tempdir().unwrap();
let line = "esto-no-existe.txt foo-tampoco.bin";
assert!(paths_in(line, d.path()).is_empty());
}
#[test]
fn box_drawing_chars_are_detected_as_one_run() {
// Tres chars contiguos U+2500..U+257F = una sola decoración.
let line = "┌───┐ texto │ otro";
let dec = decorate_line(line, Path::new("/"));
let boxes: Vec<_> = dec
.iter()
.filter(|d| matches!(d.kind, DecorationKind::BoxDraw))
.collect();
// Una para "┌───┐" y otra para "│".
assert_eq!(boxes.len(), 2);
}
#[test]
fn box_drawing_runs_are_contiguous_only() {
// Espacios cortan el run.
let line = "─ ─";
let dec = decorate_line(line, Path::new("/"));
let n_boxes = dec.iter().filter(|d| matches!(d.kind, DecorationKind::BoxDraw)).count();
assert_eq!(n_boxes, 2);
}
#[test]
fn block_elements_count_as_box_draw() {
// Bloques de progresión (cargo "███") también.
let line = "▓▓░ progreso";
let dec = decorate_line(line, Path::new("/"));
assert!(dec.iter().any(|d| matches!(d.kind, DecorationKind::BoxDraw)));
}
#[test]
fn git_sha_is_detected_with_hex_and_min_length() {
let line = "commit a1b2c3d por sergio";
let dec = decorate_line(line, Path::new("/"));
let shas: Vec<_> = dec
.iter()
.filter_map(|d| match &d.kind {
DecorationKind::GitSha(s) => Some(s.clone()),
_ => None,
})
.collect();
assert_eq!(shas, vec!["a1b2c3d"]);
}
#[test]
fn git_sha_long_form() {
let line = "ref a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
let dec = decorate_line(line, Path::new("/"));
let count = dec
.iter()
.filter(|d| matches!(d.kind, DecorationKind::GitSha(_)))
.count();
assert_eq!(count, 1);
}
#[test]
fn pure_letters_are_not_a_sha() {
// "abcdefg" es hex pero sin dígito — descarta.
let line = "abcdefg";
let dec = decorate_line(line, Path::new("/"));
assert!(!dec.iter().any(|d| matches!(d.kind, DecorationKind::GitSha(_))));
}
#[test]
fn issue_ref_is_picked_up() {
let line = "fixes #1234 y también #56";
let dec = decorate_line(line, Path::new("/"));
let refs: Vec<_> = dec
.iter()
.filter_map(|d| match &d.kind {
DecorationKind::IssueRef(n) => Some(*n),
_ => None,
})
.collect();
assert_eq!(refs, vec![1234, 56]);
}
#[test]
fn pound_inside_a_word_is_not_an_issue_ref() {
let line = "abc#123";
let dec = decorate_line(line, Path::new("/"));
assert!(!dec.iter().any(|d| matches!(d.kind, DecorationKind::IssueRef(_))));
}
}
@@ -0,0 +1,25 @@
//! El dialecto de la línea — qué sintaxis se analiza.
//!
//! Hoy sólo Bash. El tipo existe para que el shell pueda, más adelante,
//! conmutar a zsh/fish/python sin que los consumidores cambien: el
//! analizador despacha sobre el `Dialect` y cada nuevo dialecto entra
//! con su propio lexer.
use serde::{Deserialize, Serialize};
/// Sintaxis con la que se interpreta la línea de comandos.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Dialect {
/// Shell Bourne-again — el dialecto inicial.
#[default]
Bash,
}
impl Dialect {
/// Nombre legible del dialecto.
pub fn name(self) -> &'static str {
match self {
Dialect::Bash => "bash",
}
}
}
@@ -0,0 +1,281 @@
//! `LineState` — el estado editable del input del shell.
//!
//! Mantiene el texto y la posición del cursor (offset de byte, siempre
//! en un límite de carácter) y expone las operaciones de edición. Es
//! agnóstico: un frontend GPUI o TUI sólo traduce sus eventos de teclado
//! a estas llamadas y luego pinta [`LineState::tokens`].
use serde::{Deserialize, Serialize};
use crate::complete::{complete, Completion, CompletionSource};
use crate::dialect::Dialect;
use crate::lexer::tokenize;
use crate::pipeline::{split_pipeline, Pipeline};
use crate::token::Token;
/// El input del shell: texto + cursor + dialecto.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LineState {
text: String,
/// Offset de byte del cursor; invariante: siempre en límite de carácter.
cursor: usize,
dialect: Dialect,
}
impl LineState {
/// Línea vacía con el dialecto por defecto (bash).
pub fn new() -> Self {
Self::default()
}
/// Texto actual.
pub fn text(&self) -> &str {
&self.text
}
/// Posición del cursor en bytes.
pub fn cursor(&self) -> usize {
self.cursor
}
/// Dialecto activo.
pub fn dialect(&self) -> Dialect {
self.dialect
}
/// Cambia el dialecto (bash hoy; zsh/fish/python a futuro).
pub fn set_dialect(&mut self, dialect: Dialect) {
self.dialect = dialect;
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
/// Reemplaza toda la línea y deja el cursor al final.
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
self.cursor = self.text.len();
}
/// Vacía la línea.
pub fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
}
/// Inserta texto en el cursor y lo avanza.
pub fn insert(&mut self, s: &str) {
self.text.insert_str(self.cursor, s);
self.cursor += s.len();
}
/// Inserta un carácter en el cursor.
pub fn insert_char(&mut self, c: char) {
let mut buf = [0u8; 4];
self.insert(c.encode_utf8(&mut buf));
}
/// Borra el carácter a la izquierda del cursor.
pub fn backspace(&mut self) {
if let Some(prev) = self.text[..self.cursor].chars().next_back() {
let bl = prev.len_utf8();
self.text.replace_range(self.cursor - bl..self.cursor, "");
self.cursor -= bl;
}
}
/// Borra el carácter a la derecha del cursor.
pub fn delete(&mut self) {
if let Some(next) = self.text[self.cursor..].chars().next() {
let nl = next.len_utf8();
self.text.replace_range(self.cursor..self.cursor + nl, "");
}
}
/// Mueve el cursor un carácter a la izquierda.
pub fn move_left(&mut self) {
if let Some(prev) = self.text[..self.cursor].chars().next_back() {
self.cursor -= prev.len_utf8();
}
}
/// Mueve el cursor un carácter a la derecha.
pub fn move_right(&mut self) {
if let Some(next) = self.text[self.cursor..].chars().next() {
self.cursor += next.len_utf8();
}
}
/// Lleva el cursor al inicio.
pub fn move_home(&mut self) {
self.cursor = 0;
}
/// Lleva el cursor al final.
pub fn move_end(&mut self) {
self.cursor = self.text.len();
}
/// Mueve el cursor al inicio de la palabra anterior.
pub fn move_word_left(&mut self) {
let mut c = self.cursor;
let prev = |c: usize, t: &str| t[..c].chars().next_back();
// Salta el espacio en blanco, luego la palabra.
while let Some(ch) = prev(c, &self.text) {
if ch.is_whitespace() {
c -= ch.len_utf8();
} else {
break;
}
}
while let Some(ch) = prev(c, &self.text) {
if ch.is_whitespace() {
break;
}
c -= ch.len_utf8();
}
self.cursor = c;
}
/// Mueve el cursor al final de la palabra siguiente.
pub fn move_word_right(&mut self) {
let mut c = self.cursor;
let next = |c: usize, t: &str| t[c..].chars().next();
while let Some(ch) = next(c, &self.text) {
if ch.is_whitespace() {
c += ch.len_utf8();
} else {
break;
}
}
while let Some(ch) = next(c, &self.text) {
if ch.is_whitespace() {
break;
}
c += ch.len_utf8();
}
self.cursor = c;
}
/// Análisis de la línea: los tokens clasificados, listos para pintar.
pub fn tokens(&self) -> Vec<Token> {
tokenize(&self.text, self.dialect)
}
/// La línea descompuesta en etapas de pipeline.
pub fn pipeline(&self) -> Pipeline {
split_pipeline(&self.tokens())
}
/// Autocompletado en la posición actual del cursor.
pub fn complete(&self, source: &dyn CompletionSource) -> Completion {
complete(&self.text, self.cursor, self.dialect, source)
}
/// Aplica un candidato de autocompletado: reemplaza el rango que
/// indicó la [`Completion`] y deja el cursor tras lo insertado.
pub fn apply_completion(&mut self, completion: &Completion, candidate: &str) {
let (s, e) = (completion.replace_start, completion.replace_end);
if s <= e && e <= self.text.len() {
self.text.replace_range(s..e, candidate);
self.cursor = s + candidate.len();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::complete::StaticSource;
use crate::token::TokenKind;
#[test]
fn insert_advances_the_cursor() {
let mut l = LineState::new();
l.insert("ls -la");
assert_eq!(l.text(), "ls -la");
assert_eq!(l.cursor(), 6);
}
#[test]
fn backspace_removes_the_char_before_cursor() {
let mut l = LineState::new();
l.insert("abc");
l.backspace();
assert_eq!(l.text(), "ab");
assert_eq!(l.cursor(), 2);
}
#[test]
fn editing_is_utf8_safe() {
let mut l = LineState::new();
l.insert("café");
l.backspace(); // quita la 'é' (2 bytes)
assert_eq!(l.text(), "caf");
l.insert_char('é');
l.move_left();
l.move_left();
assert_eq!(l.cursor(), 2); // entre 'a' y 'f'
}
#[test]
fn delete_removes_char_at_cursor() {
let mut l = LineState::new();
l.set_text("hola");
l.move_home();
l.delete();
assert_eq!(l.text(), "ola");
}
#[test]
fn word_motions_jump_between_words() {
let mut l = LineState::new();
l.set_text("git commit now");
l.move_word_left();
assert_eq!(&l.text()[l.cursor()..], "now");
l.move_word_left();
assert_eq!(&l.text()[l.cursor()..], "commit now");
l.move_word_right();
assert_eq!(l.cursor(), "git commit".len());
}
#[test]
fn tokens_reflect_the_current_text() {
let mut l = LineState::new();
l.set_text("cat f | grep x");
let cmds: Vec<_> = l
.tokens()
.into_iter()
.filter(|t| t.kind == TokenKind::Command)
.map(|t| t.text)
.collect();
assert_eq!(cmds, vec!["cat", "grep"]);
assert!(l.pipeline().is_piped());
}
#[test]
fn apply_completion_replaces_the_prefix() {
let mut l = LineState::new();
l.insert("ca");
let source = StaticSource { commands: vec!["cargo".into()], paths: vec![] };
let c = l.complete(&source);
l.apply_completion(&c, "cargo");
assert_eq!(l.text(), "cargo");
assert_eq!(l.cursor(), 5);
}
#[test]
fn completion_after_text_keeps_the_rest() {
let mut l = LineState::new();
l.set_text("ls /home");
// Cursor tras "ls".
l.move_home();
l.move_right();
l.move_right();
let source = StaticSource { commands: vec!["lsblk".into()], paths: vec![] };
let c = l.complete(&source);
l.apply_completion(&c, "lsblk");
assert_eq!(l.text(), "lsblk /home");
}
}
@@ -0,0 +1,63 @@
//! Sugerencia fantasma — el "ghosting" predictivo del prompt.
//!
//! Mientras se escribe, el shell predice el resto de la línea y lo pinta
//! en gris tenue. Esta función es el cerebro de esa predicción: dada la
//! línea parcial y un corpus de líneas conocidas (historial, secuencias
//! inferidas), devuelve el sufijo que falta.
//!
//! El orden del corpus es la prioridad: el caller pone primero lo más
//! relevante (la secuencia predicha por `shuma-infer`), luego el
//! historial de lo más reciente a lo más viejo.
/// Devuelve el sufijo fantasma: lo que falta para completar la primera
/// entrada del `corpus` que empieza con `line` y es estrictamente más
/// larga. `None` si nada coincide.
pub fn ghost_suggestion(line: &str, corpus: &[String]) -> Option<String> {
if line.is_empty() {
return None;
}
corpus
.iter()
.find(|c| c.len() > line.len() && c.starts_with(line))
.map(|c| c[line.len()..].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn suggests_the_remainder_of_a_known_line() {
let corpus = vec!["git pull".to_string(), "cargo build".to_string()];
assert_eq!(ghost_suggestion("git pu", &corpus), Some("ll".to_string()));
}
#[test]
fn corpus_order_is_priority() {
// Dos coinciden; gana la primera del corpus.
let corpus = vec!["cargo build --release".to_string(), "cargo build".to_string()];
assert_eq!(
ghost_suggestion("cargo b", &corpus),
Some("uild --release".to_string())
);
}
#[test]
fn no_match_yields_none() {
let corpus = vec!["ls -la".to_string()];
assert_eq!(ghost_suggestion("git", &corpus), None);
}
#[test]
fn exact_line_is_not_a_suggestion() {
// El corpus contiene exactamente la línea: nada que sugerir.
let corpus = vec!["git pull".to_string()];
assert_eq!(ghost_suggestion("git pull", &corpus), None);
}
#[test]
fn empty_line_yields_none() {
let corpus = vec!["git pull".to_string()];
assert_eq!(ghost_suggestion("", &corpus), None);
}
}
@@ -0,0 +1,207 @@
//! Iconos por tipo de archivo — un glifo emoji que precede al nombre en
//! el output decorado para que un `ls` se lea como un explorador de
//! archivos en vez de una lista de tokens.
//!
//! Agnóstico de UI: devuelve un `&'static str` (emoji o símbolo) que el
//! frontend pinta tal cual antes del nombre clickeable. La elección es
//! por **tipo** primero (dir/symlink/ejecutable) y, para archivos
//! regulares, por **extensión** — el mismo criterio que un file manager.
//!
//! Espíritu del repo: no inventamos un set de iconos propio cuando el
//! `lens` de `shuma-discern` ya clasifica por familia (gallery/audio/
//! video/...). Acá cubrimos el caso del shell, donde sólo tenemos el
//! path en disco (sin samplear bytes), así que vamos por extensión.
//!
//! Dos salidas paralelas, ambas UI-agnósticas:
//! - [`file_icon`] → un emoji `&'static str` (granular: 🦀/🐍/…), pensado
//! para frontends de **terminal** donde el emoji rinde nativo.
//! - [`file_kind`] → un [`FileKind`] semántico (categoría gruesa), que un
//! frontend gráfico mapea a su propio set de iconos vectoriales (p. ej.
//! `llimphi-icons` en el shell Llimphi) sin acoplar este crate a la UI.
use std::path::Path;
/// Categoría semántica de una entrada del filesystem, para que un
/// frontend elija un icono. Gruesa a propósito: un set de iconos
/// vectoriales monocromos no distingue pdf-rojo de doc-azul, así que
/// colapsamos los documentos en uno y los lenguajes de código en otro.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum FileKind {
Folder,
Symlink,
Image,
Audio,
Video,
Archive,
/// pdf / doc / hoja de cálculo / presentación / texto / markdown.
Document,
/// Cualquier lenguaje de programación o script.
Code,
/// json / toml / yaml / xml / config.
Data,
Font,
/// Binario ejecutable u objeto (so/o/wasm/elf…).
Executable,
/// Archivo regular sin categoría reconocida.
Generic,
}
/// Clasifica una entrada ya stat-eada en una [`FileKind`]. Mismo orden
/// de decisión que [`file_icon`]: symlink y directorio mandan sobre la
/// extensión.
pub fn file_kind(path: &Path, is_dir: bool, is_executable: bool, is_symlink: bool) -> FileKind {
if is_symlink {
return FileKind::Symlink;
}
if is_dir {
return FileKind::Folder;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
if let Some(ext) = ext.as_deref() {
if let Some(kind) = kind_for_ext(ext) {
return kind;
}
}
if is_executable {
FileKind::Executable
} else {
FileKind::Generic
}
}
/// Categoría por extensión (ya en minúsculas). `None` = no reconocida.
fn kind_for_ext(ext: &str) -> Option<FileKind> {
let kind = match ext {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "ico" | "tiff" | "avif" => {
FileKind::Image
}
"mp3" | "wav" | "flac" | "ogg" | "opus" | "m4a" | "aac" | "mka" => FileKind::Audio,
"mp4" | "mkv" | "webm" | "mov" | "avi" | "ivf" | "m4v" => FileKind::Video,
"zip" | "tar" | "gz" | "xz" | "zst" | "bz2" | "7z" | "rar" | "tgz" => FileKind::Archive,
"pdf" | "doc" | "docx" | "odt" | "rtf" | "xls" | "xlsx" | "ods" | "csv" | "tsv" | "ppt"
| "pptx" | "odp" | "md" | "markdown" | "txt" | "rst" | "adoc" => FileKind::Document,
"rs" | "py" | "js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "c" | "h" | "cpp" | "hpp"
| "cc" | "go" | "java" | "kt" | "rb" | "php" | "lua" | "sh" | "bash" | "zsh" | "fish"
| "swift" | "zig" | "hs" | "ml" => FileKind::Code,
"json" | "toml" | "yaml" | "yml" | "xml" | "ini" | "conf" | "lock" => FileKind::Data,
"ttf" | "otf" | "woff" | "woff2" => FileKind::Font,
"wasm" | "so" | "a" | "o" | "dll" | "dylib" | "elf" | "bin" => FileKind::Executable,
_ => return None,
};
Some(kind)
}
/// Icono para una entrada del filesystem ya stat-eada. El orden de
/// decisión importa: symlink y directorio mandan sobre la extensión
/// (un `fotos/` sigue siendo carpeta aunque termine en algo raro).
pub fn file_icon(path: &Path, is_dir: bool, is_executable: bool, is_symlink: bool) -> &'static str {
if is_symlink {
return "🔗";
}
if is_dir {
return "📁";
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
if let Some(ext) = ext.as_deref() {
if let Some(icon) = icon_for_ext(ext) {
return icon;
}
}
// Sin extensión reconocida: un binario ejecutable se distingue de un
// archivo de texto plano.
if is_executable {
"⚙️"
} else {
"📄"
}
}
/// Icono por extensión (ya en minúsculas). `None` = no reconocida, el
/// caller cae al genérico archivo/ejecutable.
fn icon_for_ext(ext: &str) -> Option<&'static str> {
let icon = match ext {
// Imágenes
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "ico" | "tiff" | "avif" => "🖼️",
// Audio
"mp3" | "wav" | "flac" | "ogg" | "opus" | "m4a" | "aac" | "mka" => "🎵",
// Video
"mp4" | "mkv" | "webm" | "mov" | "avi" | "ivf" | "m4v" => "🎬",
// Archivos comprimidos / paquetes
"zip" | "tar" | "gz" | "xz" | "zst" | "bz2" | "7z" | "rar" | "tgz" => "📦",
// Documentos
"pdf" => "📕",
"doc" | "docx" | "odt" | "rtf" => "📘",
"xls" | "xlsx" | "ods" | "csv" | "tsv" => "📊",
"ppt" | "pptx" | "odp" => "📙",
// Texto / markup
"md" | "markdown" | "txt" | "rst" | "adoc" => "📝",
// Código
"rs" => "🦀",
"py" => "🐍",
"js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx" => "📜",
"c" | "h" | "cpp" | "hpp" | "cc" | "go" | "java" | "kt" | "rb" | "php" | "lua"
| "sh" | "bash" | "zsh" | "fish" | "swift" | "zig" | "hs" | "ml" => "📜",
// Datos / config
"json" | "toml" | "yaml" | "yml" | "xml" | "ini" | "conf" | "lock" => "🛠️",
// Fuentes
"ttf" | "otf" | "woff" | "woff2" => "🔤",
// Binarios / objetos
"wasm" | "so" | "a" | "o" | "dll" | "dylib" | "elf" | "bin" => "⚙️",
_ => return None,
};
Some(icon)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dir_and_symlink_win_over_extension() {
// Una carpeta llamada "algo.rs" sigue siendo carpeta.
assert_eq!(file_icon(Path::new("algo.rs"), true, false, false), "📁");
// Un symlink manda sobre todo.
assert_eq!(file_icon(Path::new("link.png"), false, false, true), "🔗");
}
#[test]
fn known_extensions_get_their_icon() {
assert_eq!(file_icon(Path::new("foto.PNG"), false, false, false), "🖼️");
assert_eq!(file_icon(Path::new("main.rs"), false, false, false), "🦀");
assert_eq!(file_icon(Path::new("notas.md"), false, false, false), "📝");
assert_eq!(file_icon(Path::new("data.tar.gz"), false, false, false), "📦");
}
#[test]
fn unknown_extension_falls_back_by_exec_bit() {
assert_eq!(file_icon(Path::new("raro.qwerty"), false, false, false), "📄");
assert_eq!(file_icon(Path::new("run"), false, true, false), "⚙️");
// Sin extensión, no ejecutable → archivo genérico.
assert_eq!(file_icon(Path::new("LICENSE"), false, false, false), "📄");
}
#[test]
fn file_kind_classifies_by_type_then_extension() {
// Tipo manda sobre extensión.
assert_eq!(file_kind(Path::new("algo.rs"), true, false, false), FileKind::Folder);
assert_eq!(file_kind(Path::new("link.png"), false, false, true), FileKind::Symlink);
// Por extensión (colapsa lenguajes en Code, documentos en Document).
assert_eq!(file_kind(Path::new("main.rs"), false, false, false), FileKind::Code);
assert_eq!(file_kind(Path::new("app.py"), false, false, false), FileKind::Code);
assert_eq!(file_kind(Path::new("foto.PNG"), false, false, false), FileKind::Image);
assert_eq!(file_kind(Path::new("doc.pdf"), false, false, false), FileKind::Document);
assert_eq!(file_kind(Path::new("notas.md"), false, false, false), FileKind::Document);
assert_eq!(file_kind(Path::new("data.tar.gz"), false, false, false), FileKind::Archive);
assert_eq!(file_kind(Path::new("cfg.toml"), false, false, false), FileKind::Data);
assert_eq!(file_kind(Path::new("font.ttf"), false, false, false), FileKind::Font);
// Fallback por bit ejecutable.
assert_eq!(file_kind(Path::new("run"), false, true, false), FileKind::Executable);
assert_eq!(file_kind(Path::new("LICENSE"), false, false, false), FileKind::Generic);
}
}
@@ -0,0 +1,341 @@
//! El lexer — convierte una línea de texto en tokens clasificados.
//!
//! Dos pasadas: un escaneo léxico que reconoce comillas, variables,
//! tuberías, redirecciones, operadores y palabras; y una pasada de
//! clasificación que distingue el *comando* (la primera palabra de cada
//! etapa) de sus *argumentos*.
use crate::dialect::Dialect;
use crate::token::{Token, TokenKind};
/// Analiza `input` según `dialect` y devuelve los tokens, contiguos y
/// clasificados, cubriendo toda la línea.
pub fn tokenize(input: &str, dialect: Dialect) -> Vec<Token> {
let raw = match dialect {
Dialect::Bash => scan_bash(input),
};
classify(raw)
}
/// `true` si `c` corta una palabra suelta.
fn is_word_break(c: char) -> bool {
c.is_whitespace() || matches!(c, '|' | '&' | ';' | '<' | '>' | '"' | '\'' | '$')
}
/// Detecta una redirección a partir de `p`: un dígito opcional, luego
/// `>`/`<`, y un segundo `>`/`<` opcional (`>>`, `<<`). Devuelve la
/// posición final, o `None`.
fn try_redirect(chars: &[(usize, char)], p: usize) -> Option<usize> {
let n = chars.len();
let mut q = p;
if q < n && chars[q].1.is_ascii_digit() {
q += 1;
}
if q < n && (chars[q].1 == '>' || chars[q].1 == '<') {
let r = chars[q].1;
q += 1;
if q < n && chars[q].1 == r {
q += 1;
}
Some(q)
} else {
None
}
}
/// Escaneo léxico de Bash.
fn scan_bash(input: &str) -> Vec<Token> {
let chars: Vec<(usize, char)> = input.char_indices().collect();
let n = chars.len();
let byte_at = |p: usize| if p < n { chars[p].0 } else { input.len() };
let mut tokens: Vec<Token> = Vec::new();
let push = |tokens: &mut Vec<Token>, kind: TokenKind, sp: usize, ep: usize| {
let (sb, eb) = (byte_at(sp), byte_at(ep));
tokens.push(Token::new(kind, sb, eb, &input[sb..eb]));
};
let mut p = 0;
while p < n {
let c = chars[p].1;
// Espacio en blanco.
if c.is_whitespace() {
let mut q = p;
while q < n && chars[q].1.is_whitespace() {
q += 1;
}
push(&mut tokens, TokenKind::Whitespace, p, q);
p = q;
continue;
}
// Comentario hasta fin de línea.
if c == '#' {
let mut q = p;
while q < n && chars[q].1 != '\n' {
q += 1;
}
push(&mut tokens, TokenKind::Comment, p, q);
p = q;
continue;
}
// Cadena entre comillas simples — literal.
if c == '\'' {
let mut q = p + 1;
while q < n && chars[q].1 != '\'' {
q += 1;
}
if q < n {
q += 1; // incluye la comilla de cierre
}
push(&mut tokens, TokenKind::StringLit, p, q);
p = q;
continue;
}
// Cadena entre comillas dobles — admite `\"`.
if c == '"' {
let mut q = p + 1;
while q < n {
if chars[q].1 == '\\' && q + 1 < n {
q += 2;
continue;
}
if chars[q].1 == '"' {
break;
}
q += 1;
}
if q < n {
q += 1;
}
push(&mut tokens, TokenKind::StringLit, p, q);
p = q;
continue;
}
// Variable / sustitución.
if c == '$' {
let mut q = p + 1;
if q < n && chars[q].1 == '{' {
while q < n && chars[q].1 != '}' {
q += 1;
}
if q < n {
q += 1;
}
} else if q < n && chars[q].1 == '(' {
let mut depth = 0;
while q < n {
match chars[q].1 {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
q += 1;
break;
}
}
_ => {}
}
q += 1;
}
} else {
while q < n && (chars[q].1.is_alphanumeric() || chars[q].1 == '_') {
q += 1;
}
}
push(&mut tokens, TokenKind::Variable, p, q);
p = q;
continue;
}
// Tubería vs. OR lógico.
if c == '|' {
if p + 1 < n && chars[p + 1].1 == '|' {
push(&mut tokens, TokenKind::Operator, p, p + 2);
p += 2;
} else {
push(&mut tokens, TokenKind::Pipe, p, p + 1);
p += 1;
}
continue;
}
// `&&`, `&>`, `&`.
if c == '&' {
if p + 1 < n && chars[p + 1].1 == '&' {
push(&mut tokens, TokenKind::Operator, p, p + 2);
p += 2;
} else if p + 1 < n && chars[p + 1].1 == '>' {
push(&mut tokens, TokenKind::Redirect, p, p + 2);
p += 2;
} else {
push(&mut tokens, TokenKind::Operator, p, p + 1);
p += 1;
}
continue;
}
// Separador de comandos.
if c == ';' {
push(&mut tokens, TokenKind::Operator, p, p + 1);
p += 1;
continue;
}
// Redirección (con dígito de descriptor opcional).
if let Some(q) = try_redirect(&chars, p) {
push(&mut tokens, TokenKind::Redirect, p, q);
p = q;
continue;
}
// Palabra suelta — argumento o flag.
let mut q = p;
while q < n && !is_word_break(chars[q].1) {
q += 1;
}
if q == p {
// Carácter aislado no reconocido: no estancar el bucle.
push(&mut tokens, TokenKind::Unknown, p, p + 1);
p += 1;
} else {
let kind = if chars[p].1 == '-' {
TokenKind::Flag
} else {
TokenKind::Argument
};
push(&mut tokens, kind, p, q);
p = q;
}
}
tokens
}
/// Segunda pasada: la primera palabra de cada etapa es el comando.
fn classify(mut tokens: Vec<Token>) -> Vec<Token> {
let mut expect_command = true;
for t in &mut tokens {
match t.kind {
TokenKind::Whitespace | TokenKind::Comment | TokenKind::Redirect => {}
TokenKind::Pipe | TokenKind::Operator => expect_command = true,
TokenKind::Argument => {
if expect_command {
t.kind = TokenKind::Command;
}
expect_command = false;
}
_ => expect_command = false,
}
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(input: &str) -> Vec<TokenKind> {
tokenize(input, Dialect::Bash)
.into_iter()
.filter(|t| t.kind != TokenKind::Whitespace)
.map(|t| t.kind)
.collect()
}
#[test]
fn tokens_cover_the_whole_line() {
let input = "ls -la /home";
let toks = tokenize(input, Dialect::Bash);
assert_eq!(toks.first().unwrap().start, 0);
assert_eq!(toks.last().unwrap().end, input.len());
for w in toks.windows(2) {
assert_eq!(w[0].end, w[1].start, "los tokens son contiguos");
}
}
#[test]
fn first_word_is_the_command() {
assert_eq!(
kinds("ls -la /home"),
vec![TokenKind::Command, TokenKind::Flag, TokenKind::Argument]
);
}
#[test]
fn word_after_pipe_is_a_command_again() {
let k = kinds("cat file | grep error");
assert_eq!(
k,
vec![
TokenKind::Command,
TokenKind::Argument,
TokenKind::Pipe,
TokenKind::Command,
TokenKind::Argument,
]
);
}
#[test]
fn operators_reset_the_command_position() {
let k = kinds("make && ./run ; echo done");
assert_eq!(k[0], TokenKind::Command); // make
assert_eq!(k[2], TokenKind::Command); // ./run, tras &&
assert_eq!(k[4], TokenKind::Command); // echo, tras ;
assert_eq!(k[5], TokenKind::Argument); // done
}
#[test]
fn quotes_are_single_string_tokens() {
assert_eq!(
kinds("echo \"hola mundo\" 'literal'"),
vec![TokenKind::Command, TokenKind::StringLit, TokenKind::StringLit]
);
}
#[test]
fn variables_are_recognized() {
assert_eq!(
kinds("echo $HOME ${PATH} $(date)"),
vec![
TokenKind::Command,
TokenKind::Variable,
TokenKind::Variable,
TokenKind::Variable,
]
);
}
#[test]
fn redirects_with_descriptors() {
let k = kinds("cmd 2> err.log >> out.log");
assert_eq!(k[1], TokenKind::Redirect);
assert_eq!(k[3], TokenKind::Redirect);
}
#[test]
fn pipe_distinct_from_logical_or() {
assert_eq!(kinds("a | b")[1], TokenKind::Pipe);
assert_eq!(kinds("a || b")[1], TokenKind::Operator);
}
#[test]
fn comment_runs_to_end_of_line() {
let k = kinds("ls # esto es un comentario");
assert_eq!(k, vec![TokenKind::Command, TokenKind::Comment]);
}
#[test]
fn handles_unicode_without_panicking() {
let toks = tokenize("echo 'añoño café' ☕", Dialect::Bash);
assert_eq!(toks.last().unwrap().end, "echo 'añoño café' ☕".len());
}
#[test]
fn empty_line_yields_no_tokens() {
assert!(tokenize("", Dialect::Bash).is_empty());
}
}
@@ -0,0 +1,48 @@
//! `shuma-line` — el cerebro del input del shell.
//!
//! La función principal del shell es su línea de comandos, y esta línea
//! no es un campo de texto tonto: analiza lo que se escribe para
//! resaltarlo, autocompletarlo y entender sus tuberías. Toda esa
//! inteligencia vive aquí, **agnóstica de frontend** — la usa igual el
//! shell GPUI de brahman que una versión TUI.
//!
//! - [`dialect`] — el [`Dialect`] de la línea (bash hoy; zsh/fish/python
//! a futuro, conmutable).
//! - [`token`] — el [`Token`] y su [`TokenKind`] (la clase de resaltado).
//! - [`lexer`] — [`tokenize`]: el análisis léxico + clasificación.
//! - [`pipeline`] — [`split_pipeline`]: la línea descompuesta en etapas
//! separadas por `|`.
//! - [`complete`] — el motor de autocompletado y su [`CompletionSource`].
//! - [`editor`] — [`LineState`], el estado editable del input.
//!
//! Un frontend traduce sus eventos de teclado a métodos de `LineState` y
//! pinta `LineState::tokens()` con un color por `TokenKind`. Nada más.
#![forbid(unsafe_code)]
pub mod ansi;
pub mod complete;
pub mod continuation;
pub mod decorate;
pub mod dialect;
pub mod editor;
pub mod ghost;
pub mod lexer;
pub mod pipeline;
pub mod token;
pub use ansi::{parse_ansi_line, strip_ansi, AnsiColor, AnsiSpan, AnsiStyle};
pub use complete::{
complete, flag_hints, Completion, CompletionKind, CompletionSource, StaticSource,
};
pub use continuation::needs_continuation;
pub use decorate::{decorate_line, Decoration, DecorationKind};
pub use dialect::Dialect;
pub use editor::LineState;
pub use ghost::ghost_suggestion;
pub use lexer::tokenize;
pub use pipeline::{split_pipeline, Pipeline, Stage};
pub use token::{Token, TokenKind};
pub mod icon;
pub use icon::{file_icon, file_kind, FileKind};
@@ -0,0 +1,127 @@
//! Pipeline — la línea descompuesta en sus etapas separadas por `|`.
//!
//! Procesar los pipes es el primer paso para que el shell sea inteligente
//! con la línea: saber cuántas etapas hay, cuál es el comando de cada
//! una y qué argumentos lleva.
use serde::{Deserialize, Serialize};
use crate::token::{Token, TokenKind};
/// Una etapa del pipeline — un comando y sus argumentos.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Stage {
/// Nombre del comando, si la etapa lo tiene.
pub command: Option<String>,
/// Argumentos y flags, en orden de aparición.
pub args: Vec<String>,
/// Todos los tokens de la etapa (sin la `|` que la separa).
pub tokens: Vec<Token>,
}
impl Stage {
fn from_tokens(tokens: Vec<Token>) -> Self {
let mut command = None;
let mut args = Vec::new();
for t in &tokens {
match t.kind {
TokenKind::Command => command = Some(t.text.clone()),
TokenKind::Argument | TokenKind::Flag => args.push(t.text.clone()),
_ => {}
}
}
Self { command, args, tokens }
}
}
/// La línea completa descompuesta en etapas de pipeline.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Pipeline {
pub stages: Vec<Stage>,
}
impl Pipeline {
/// Cantidad de etapas.
pub fn len(&self) -> usize {
self.stages.len()
}
pub fn is_empty(&self) -> bool {
self.stages.is_empty()
}
/// `true` si la línea encadena dos o más comandos por `|`.
pub fn is_piped(&self) -> bool {
self.stages.len() > 1
}
}
/// Descompone los tokens clasificados en etapas separadas por `|`.
/// El espacio en blanco a los lados se conserva dentro de cada etapa;
/// una etapa vacía (p. ej. la línea termina en `|`) también cuenta.
pub fn split_pipeline(tokens: &[Token]) -> Pipeline {
if tokens.is_empty() {
return Pipeline::default();
}
let mut stages = Vec::new();
let mut current: Vec<Token> = Vec::new();
for t in tokens {
if t.kind == TokenKind::Pipe {
stages.push(Stage::from_tokens(std::mem::take(&mut current)));
} else {
current.push(t.clone());
}
}
stages.push(Stage::from_tokens(current));
Pipeline { stages }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dialect::Dialect;
use crate::lexer::tokenize;
fn pipeline(line: &str) -> Pipeline {
split_pipeline(&tokenize(line, Dialect::Bash))
}
#[test]
fn single_command_is_one_stage() {
let p = pipeline("ls -la");
assert_eq!(p.len(), 1);
assert!(!p.is_piped());
assert_eq!(p.stages[0].command.as_deref(), Some("ls"));
assert_eq!(p.stages[0].args, vec!["-la"]);
}
#[test]
fn pipe_creates_two_stages() {
let p = pipeline("cat data.json | grep error");
assert_eq!(p.len(), 2);
assert!(p.is_piped());
assert_eq!(p.stages[0].command.as_deref(), Some("cat"));
assert_eq!(p.stages[1].command.as_deref(), Some("grep"));
assert_eq!(p.stages[1].args, vec!["error"]);
}
#[test]
fn three_stage_pipeline() {
let p = pipeline("cat f | sort | uniq -c");
assert_eq!(p.len(), 3);
assert_eq!(p.stages[2].command.as_deref(), Some("uniq"));
assert_eq!(p.stages[2].args, vec!["-c"]);
}
#[test]
fn trailing_pipe_leaves_an_empty_stage() {
let p = pipeline("ls |");
assert_eq!(p.len(), 2);
assert_eq!(p.stages[1].command, None);
}
#[test]
fn empty_line_has_no_stages() {
assert!(pipeline("").is_empty());
}
}
@@ -0,0 +1,76 @@
//! Tokens — los fragmentos clasificados de una línea de comandos.
//!
//! El análisis recubre la línea entera: los tokens son contiguos (cada
//! byte cae en exactamente uno, incluido el espacio en blanco). Así un
//! frontend —GPUI o TUI— sólo recorre los tokens y pinta cada uno con el
//! color de su [`TokenKind`].
use serde::{Deserialize, Serialize};
/// Clase de un token — y, a la vez, su clase de resaltado.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TokenKind {
/// El nombre del programa a ejecutar (primera palabra de una etapa).
Command,
/// Un argumento simple.
Argument,
/// Una opción — empieza con `-` o `--`.
Flag,
/// Una cadena entre comillas (`"..."` o `'...'`).
StringLit,
/// Una expansión de variable o sustitución (`$VAR`, `${VAR}`, `$(...)`).
Variable,
/// El operador de tubería `|`.
Pipe,
/// Una redirección (`>`, `>>`, `<`, `2>`, `&>`).
Redirect,
/// Un operador de secuencia o lógico (`&&`, `||`, `;`, `&`).
Operator,
/// Un comentario (`# ...`).
Comment,
/// Espacio en blanco.
Whitespace,
/// Algo que el lexer no supo clasificar.
Unknown,
}
impl TokenKind {
/// `true` si el token lleva contenido del usuario (no es separador).
pub fn is_content(self) -> bool {
matches!(
self,
TokenKind::Command
| TokenKind::Argument
| TokenKind::Flag
| TokenKind::StringLit
| TokenKind::Variable
)
}
}
/// Un fragmento clasificado de la línea, con su rango en bytes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Token {
pub kind: TokenKind,
/// Offset de byte donde empieza (inclusivo).
pub start: usize,
/// Offset de byte donde termina (exclusivo).
pub end: usize,
/// El texto del token.
pub text: String,
}
impl Token {
pub(crate) fn new(kind: TokenKind, start: usize, end: usize, text: &str) -> Self {
Self { kind, start, end, text: text.to_string() }
}
/// Largo en bytes.
pub fn len(&self) -> usize {
self.end - self.start
}
pub fn is_empty(&self) -> bool {
self.start == self.end
}
}

Some files were not shown because too many files have changed in this diff Show More