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:
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
*.pdb
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 A–F 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(¤t_inventory(discover, ¤t, &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(¤t_inventory(discover, ¤t, &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(¤t_inventory(discover, ¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t, &desired), plan(¤t, &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
|
||||||
|
//! append‑only fácil de leer, rotar y compartir entre sesiones.
|
||||||
|
//!
|
||||||
|
//! Diseño:
|
||||||
|
//!
|
||||||
|
//! - **JSONL** (`{"line":...,"cwd":...,"exit":...,"started":...,"duration_ms":...}`).
|
||||||
|
//! Una entrada por línea, append‑only — 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
|
||||||
|
//! helix‑editor: rápido, Unicode‑correct, 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, ¶ms);
|
||||||
|
}
|
||||||
|
// 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
Reference in New Issue
Block a user