commit 1e5d3ed2397d6efbb71aa34380f170471508cfd7 Author: Sergio Date: Thu Jun 4 11:35:41 2026 +0000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7141ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +*.pdb diff --git a/02_ruway/shuma/LEEME.md b/02_ruway/shuma/LEEME.md new file mode 100644 index 0000000..fed95fe --- /dev/null +++ b/02_ruway/shuma/LEEME.md @@ -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. diff --git a/02_ruway/shuma/README.md b/02_ruway/shuma/README.md new file mode 100644 index 0000000..4f1b379 --- /dev/null +++ b/02_ruway/shuma/README.md @@ -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. diff --git a/02_ruway/shuma/README.qu.md b/02_ruway/shuma/README.qu.md new file mode 100644 index 0000000..75bcfef --- /dev/null +++ b/02_ruway/shuma/README.qu.md @@ -0,0 +1,29 @@ + + +# 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. diff --git a/02_ruway/shuma/REPORTE.md b/02_ruway/shuma/REPORTE.md new file mode 100644 index 0000000..ea2696b --- /dev/null +++ b/02_ruway/shuma/REPORTE.md @@ -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), 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), 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` 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>`; `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 `. 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)` 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, 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` 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.* diff --git a/02_ruway/shuma/baremetal/matilda-app/Cargo.toml b/02_ruway/shuma/baremetal/matilda-app/Cargo.toml new file mode 100644 index 0000000..8fffe1a --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-app/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/baremetal/matilda-app/LEEME.md b/02_ruway/shuma/baremetal/matilda-app/LEEME.md new file mode 100644 index 0000000..2051f13 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-app/LEEME.md @@ -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/) diff --git a/02_ruway/shuma/baremetal/matilda-app/README.md b/02_ruway/shuma/baremetal/matilda-app/README.md new file mode 100644 index 0000000..397bd56 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-app/README.md @@ -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/) diff --git a/02_ruway/shuma/baremetal/matilda-app/src/main.rs b/02_ruway/shuma/baremetal/matilda-app/src/main.rs new file mode 100644 index 0000000..80cd944 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-app/src/main.rs @@ -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, + /// 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, + #[arg(long)] + discover: bool, + }, + /// Aplica el plan: local, en seco, o remoto por SSH. + Apply { + inventory: PathBuf, + #[arg(long)] + current: Option, + /// 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, + /// Contraseña SSH (si no se da, se usa la clave por defecto). + #[arg(long)] + password: Option, + }, +} + +/// Carga un inventario JSON desde un archivo. +fn load(path: &PathBuf) -> Result { + 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, + desired: &Inventory, +) -> Result { + 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, + steps: &[ApplyStep], +) -> Result { + 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 + } + } +} diff --git a/02_ruway/shuma/baremetal/matilda-apply/Cargo.toml b/02_ruway/shuma/baremetal/matilda-apply/Cargo.toml new file mode 100644 index 0000000..b1f903d --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-apply/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/baremetal/matilda-apply/LEEME.md b/02_ruway/shuma/baremetal/matilda-apply/LEEME.md new file mode 100644 index 0000000..0429010 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-apply/LEEME.md @@ -0,0 +1,10 @@ +# matilda-apply + +> Ejecutor del plan de [matilda](../../README.md). + +Aplica `Vec` 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` diff --git a/02_ruway/shuma/baremetal/matilda-apply/README.md b/02_ruway/shuma/baremetal/matilda-apply/README.md new file mode 100644 index 0000000..d163512 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-apply/README.md @@ -0,0 +1,10 @@ +# matilda-apply + +> Plan executor of [matilda](../../README.md). + +Applies `Vec` 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` diff --git a/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs b/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs new file mode 100644 index 0000000..353c08f --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-apply/src/lib.rs @@ -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, + /// Comandos de shell a ejecutar, en orden. + pub commands: Vec, +} + +/// 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 { + 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")); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-config/Cargo.toml b/02_ruway/shuma/baremetal/matilda-config/Cargo.toml new file mode 100644 index 0000000..70f5434 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-config/Cargo.toml @@ -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" } diff --git a/02_ruway/shuma/baremetal/matilda-config/LEEME.md b/02_ruway/shuma/baremetal/matilda-config/LEEME.md new file mode 100644 index 0000000..72fb835 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-config/LEEME.md @@ -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` diff --git a/02_ruway/shuma/baremetal/matilda-config/README.md b/02_ruway/shuma/baremetal/matilda-config/README.md new file mode 100644 index 0000000..7da49c1 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-config/README.md @@ -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` diff --git a/02_ruway/shuma/baremetal/matilda-config/src/docker.rs b/02_ruway/shuma/baremetal/matilda-config/src/docker.rs new file mode 100644 index 0000000..0259ef7 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-config/src/docker.rs @@ -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 = 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:")); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-config/src/lib.rs b/02_ruway/shuma/baremetal/matilda-config/src/lib.rs new file mode 100644 index 0000000..6682e08 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-config/src/lib.rs @@ -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::>() + .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;")); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-config/src/nginx.rs b/02_ruway/shuma/baremetal/matilda-config/src/nginx.rs new file mode 100644 index 0000000..e2a1425 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-config/src/nginx.rs @@ -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)); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-core/Cargo.toml b/02_ruway/shuma/baremetal/matilda-core/Cargo.toml new file mode 100644 index 0000000..2bd92c9 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-core/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/baremetal/matilda-core/LEEME.md b/02_ruway/shuma/baremetal/matilda-core/LEEME.md new file mode 100644 index 0000000..deb5ec4 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-core/LEEME.md @@ -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` diff --git a/02_ruway/shuma/baremetal/matilda-core/README.md b/02_ruway/shuma/baremetal/matilda-core/README.md new file mode 100644 index 0000000..1bfa2ed --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-core/README.md @@ -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` diff --git a/02_ruway/shuma/baremetal/matilda-core/src/container.rs b/02_ruway/shuma/baremetal/matilda-core/src/container.rs new file mode 100644 index 0000000..a731a25 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-core/src/container.rs @@ -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, + /// 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, image: impl Into) -> 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, value: impl Into) -> 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, + container_path: impl Into, + ) -> 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); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-core/src/host.rs b/02_ruway/shuma/baremetal/matilda-core/src/host.rs new file mode 100644 index 0000000..75bc095 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-core/src/host.rs @@ -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, +} + +impl Host { + pub fn new(name: impl Into, address: impl Into) -> 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) -> 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")); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs b/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs new file mode 100644 index 0000000..01259a3 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-core/src/inventory.rs @@ -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, + containers: BTreeMap, + vhosts: BTreeMap, +} + +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 { + 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 { + 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 { + 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"]); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-core/src/lib.rs b/02_ruway/shuma/baremetal/matilda-core/src/lib.rs new file mode 100644 index 0000000..5135abb --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-core/src/lib.rs @@ -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}; diff --git a/02_ruway/shuma/baremetal/matilda-core/src/vhost.rs b/02_ruway/shuma/baremetal/matilda-core/src/vhost.rs new file mode 100644 index 0000000..4c48fb9 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-core/src/vhost.rs @@ -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, +} + +impl VHost { + /// VHost que apunta a una dirección literal. + pub fn to_address(domain: impl Into, address: impl Into) -> 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, + container: impl Into, + 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) -> 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); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-discover/Cargo.toml b/02_ruway/shuma/baremetal/matilda-discover/Cargo.toml new file mode 100644 index 0000000..f4e5297 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-discover/Cargo.toml @@ -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" } diff --git a/02_ruway/shuma/baremetal/matilda-discover/LEEME.md b/02_ruway/shuma/baremetal/matilda-discover/LEEME.md new file mode 100644 index 0000000..a97dd47 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-discover/LEEME.md @@ -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` diff --git a/02_ruway/shuma/baremetal/matilda-discover/README.md b/02_ruway/shuma/baremetal/matilda-discover/README.md new file mode 100644 index 0000000..48da119 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-discover/README.md @@ -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` diff --git a/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs b/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs new file mode 100644 index 0000000..4feb9d0 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-discover/src/lib.rs @@ -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, + /// Dominios de los vhosts presentes. + pub vhosts: Vec, +} + +/// Parsea la salida de `docker ps -a --format '{{.Names}}'` — un nombre +/// por línea. +pub fn parse_docker_names(text: &str) -> Vec { + 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 { + 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 { + 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, +} + +#[derive(Debug, Default, Deserialize)] +struct DockerHostConfig { + #[serde(default, rename = "Binds")] + binds: Option>, + #[serde(default, rename = "PortBindings")] + port_bindings: std::collections::HashMap>>, + #[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 = 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")); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml b/02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml new file mode 100644 index 0000000..64fe4fa --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-ghost/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/baremetal/matilda-ghost/LEEME.md b/02_ruway/shuma/baremetal/matilda-ghost/LEEME.md new file mode 100644 index 0000000..e1be2ca --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-ghost/LEEME.md @@ -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) diff --git a/02_ruway/shuma/baremetal/matilda-ghost/README.md b/02_ruway/shuma/baremetal/matilda-ghost/README.md new file mode 100644 index 0000000..c988fa4 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-ghost/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) diff --git a/02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs b/02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs new file mode 100644 index 0000000..bc4d9db --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-ghost/src/lib.rs @@ -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, +} + +/// 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, +} + +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)> { + 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, 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); + } +} diff --git a/02_ruway/shuma/baremetal/matilda-linker/Cargo.toml b/02_ruway/shuma/baremetal/matilda-linker/Cargo.toml new file mode 100644 index 0000000..4ea4830 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-linker/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/baremetal/matilda-linker/LEEME.md b/02_ruway/shuma/baremetal/matilda-linker/LEEME.md new file mode 100644 index 0000000..1b265a3 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-linker/LEEME.md @@ -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) diff --git a/02_ruway/shuma/baremetal/matilda-linker/README.md b/02_ruway/shuma/baremetal/matilda-linker/README.md new file mode 100644 index 0000000..80f6878 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-linker/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) diff --git a/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs b/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs new file mode 100644 index 0000000..e1efc8a --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-linker/src/lib.rs @@ -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 { + 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 { + 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. +} diff --git a/02_ruway/shuma/baremetal/matilda-plan/Cargo.toml b/02_ruway/shuma/baremetal/matilda-plan/Cargo.toml new file mode 100644 index 0000000..983b530 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-plan/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/baremetal/matilda-plan/LEEME.md b/02_ruway/shuma/baremetal/matilda-plan/LEEME.md new file mode 100644 index 0000000..87c865f --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-plan/LEEME.md @@ -0,0 +1,9 @@ +# matilda-plan + +> Planificador de diff (actual → deseado) de [matilda](../../README.md). + +Toma actual + deseado, produce `Vec` ordenada por dependencia. Cada `Action` es atómica y reversible. + +## Deps + +- [`matilda-core`](../matilda-core/README.md), [`matilda-discover`](../matilda-discover/README.md) diff --git a/02_ruway/shuma/baremetal/matilda-plan/README.md b/02_ruway/shuma/baremetal/matilda-plan/README.md new file mode 100644 index 0000000..5540c04 --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-plan/README.md @@ -0,0 +1,9 @@ +# matilda-plan + +> Diff planner (actual → desired) of [matilda](../../README.md). + +Takes actual + desired, produces a dependency-ordered `Vec`. Each `Action` is atomic and reversible. + +## Deps + +- [`matilda-core`](../matilda-core/README.md), [`matilda-discover`](../matilda-discover/README.md) diff --git a/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs b/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs new file mode 100644 index 0000000..09de1ea --- /dev/null +++ b/02_ruway/shuma/baremetal/matilda-plan/src/lib.rs @@ -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) -> 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, +} + +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 = 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»"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-card/Cargo.toml b/02_ruway/shuma/sandbox/shuma-card/Cargo.toml new file mode 100644 index 0000000..810de74 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-card/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/sandbox/shuma-card/LEEME.md b/02_ruway/shuma/sandbox/shuma-card/LEEME.md new file mode 100644 index 0000000..5f77c7a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-card/LEEME.md @@ -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) diff --git a/02_ruway/shuma/sandbox/shuma-card/README.md b/02_ruway/shuma/sandbox/shuma-card/README.md new file mode 100644 index 0000000..29de46e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-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) diff --git a/02_ruway/shuma/sandbox/shuma-card/src/lib.rs b/02_ruway/shuma/sandbox/shuma-card/src/lib.rs new file mode 100644 index 0000000..1972c63 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-card/src/lib.rs @@ -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, + + /// Slots de flow pre-declarados. Limitan qué consumidores externos al + /// workspace pueden empatar contra los productores internos. + #[serde(default)] + pub flow_dirs: Vec, + + /// 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(d: &Option, s: S) -> Result { + d.map(|x| x.as_millis() as u64).serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let v: Option = 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, + #[serde(default)] + pub edges: Vec, + #[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 { + 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 { + 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, + argv: Vec, + isolation: IsolationLevel, + ) -> Result { + 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 { + 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(a: Option, b: Option) -> Option { + 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 { + 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 { + 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, +) -> Result { + 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) { + 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 { + 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); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-config/Cargo.toml b/02_ruway/shuma/sandbox/shuma-config/Cargo.toml new file mode 100644 index 0000000..816b07d --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-config/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/sandbox/shuma-config/LEEME.md b/02_ruway/shuma/sandbox/shuma-config/LEEME.md new file mode 100644 index 0000000..20c68d8 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-config/LEEME.md @@ -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` diff --git a/02_ruway/shuma/sandbox/shuma-config/README.md b/02_ruway/shuma/sandbox/shuma-config/README.md new file mode 100644 index 0000000..06c7cd9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-config/README.md @@ -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` diff --git a/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml b/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.toml new file mode 100644 index 0000000..eef0d1b --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-config/completions.example/cargo.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", +] diff --git a/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml b/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml new file mode 100644 index 0000000..38388f8 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-config/shumarc.example.toml @@ -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. +# — 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/.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. diff --git a/02_ruway/shuma/sandbox/shuma-config/src/lib.rs b/02_ruway/shuma/sandbox/shuma-config/src/lib.rs new file mode 100644 index 0000000..9524ec0 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-config/src/lib.rs @@ -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, +} + +fn default_segments() -> Vec { + 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, + /// Variables de entorno a exportar al proceso del shell al cargar. + #[serde(default)] + pub env: HashMap, + #[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 { + 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 `.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 { + 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) -> Result { + 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 { + 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 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 `.toml`: +/// +/// ```toml +/// flags = ["--foo", "--bar=", "-x"] +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct CommandCompletion { + #[serde(default)] + pub flags: Vec, +} + +impl CommandCompletion { + /// Carga `/.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 { + 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 { + 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::(&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")); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-core/Cargo.toml b/02_ruway/shuma/sandbox/shuma-core/Cargo.toml new file mode 100644 index 0000000..5f22db8 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/sandbox/shuma-core/LEEME.md b/02_ruway/shuma/sandbox/shuma-core/LEEME.md new file mode 100644 index 0000000..4332090 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/LEEME.md @@ -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` diff --git a/02_ruway/shuma/sandbox/shuma-core/README.md b/02_ruway/shuma/sandbox/shuma-core/README.md new file mode 100644 index 0000000..19a39c4 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/README.md @@ -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` diff --git a/02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs b/02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs new file mode 100644 index 0000000..a56797f --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/flow_channel.rs @@ -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>>, + replay: Arc>>>>, + replay_caps: ReplayCaps, + socket_path: PathBuf, + meter: Arc, + _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>, +} + +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>>, + replay: Arc>>>>, + replay_caps: ReplayCaps, + meter: Arc, +} + +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>) { + 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>>, 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::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::with_replay_caps(socket_path, ReplayCaps::chunks_only(chunks)) + } + + pub fn with_replay_caps(socket_path: PathBuf, caps: ReplayCaps) -> std::io::Result { + 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::>>(BROADCAST_CAP); + let replay: Arc>>>> = + 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>> = { + 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) { + 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-.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()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-core/src/lib.rs b/02_ruway/shuma/sandbox/shuma-core/src/lib.rs new file mode 100644 index 0000000..a97d7ba --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/lib.rs @@ -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, + 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, +} + +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, + /// Ring buffer del stdout. `None` para comandos sin captura. + pub stdout: Option, + /// Ring buffer del stderr. Separado de `stdout` para que el CLI + /// pueda filtrarlos. `None` para comandos sin captura. + pub stderr: Option, + /// Si el comando fue lanzado como parte de un Pipeline, su ULID. + pub pipeline_id: Option, +} + +/// 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>, + incarnator: Arc, + /// 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, + /// 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, + /// 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>, + /// 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, + /// 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, + /// Cola de pipelines pendientes de restart. El daemon la drena en + /// cada loop del reaper, hace stop + run_pipeline. + pending_pipeline_restarts: Vec, +} + +#[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, + 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, + pub log_bytes: u64, +} + +/// Lee VmRSS (bytes) de `/proc//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 { + 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::().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; diff --git a/02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs b/02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs new file mode 100644 index 0000000..a5452c0 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/logbuf.rs @@ -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>, +} + +#[derive(Debug)] +struct Inner { + /// Bytes raw. Cuando se acerca al cap, descartamos head para mantener + /// el tail. + buf: Vec, + 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 { + 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); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-core/src/persist.rs b/02_ruway/shuma/sandbox/shuma-core/src/persist.rs new file mode 100644 index 0000000..2d34109 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/persist.rs @@ -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, + #[serde(default)] + pub saved_pipelines: Vec, + /// Pipelines vivos con supervisor (`restart_on_failure=true`) al + /// momento del snapshot. El daemon los relanza al restore. + #[serde(default)] + pub live_pipelines: Vec, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedStats { + pub commands_alive: u32, + pub commands_total: u32, + pub rss_bytes: Option, + pub rss_peak_bytes: Option, + pub cpu_usec: Option, + pub cpu_percent: Option, + 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 { + 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 = 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, + path: &Path, + ) -> anyhow::Result { + 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, +} + +#[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")); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs b/02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs new file mode 100644 index 0000000..6f016f6 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/pipeline.rs @@ -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, +} + +#[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, + /// 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, +} + +/// 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, + incarnator: Arc, + manager: Option>, +) -> Result { + 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![Vec::new(); n]; + let mut predecessors: Vec> = 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 = vec![-1; spec.edges.len()]; + let mut edge_w: Vec = 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> = vec![None; n]; + let mut producer_stdout_fd: Vec> = vec![None; n]; + let mut splitter_specs: Vec = Vec::new(); + let mut merger_specs: Vec = 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 = Vec::with_capacity(consumers[i].len()); + let mut edge_meta: Vec = 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 = 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 = Vec::new(); + let mut splitter_channels: Vec>> = + Vec::with_capacity(splitter_specs.len()); + let mut edge_socket_for_splitter: Vec>> = 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 = 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> = Vec::new(); + for m in merger_specs { + merger_handles.push(spawn_merger(m)); + } + let mut tap_handles: Vec = 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, + edges: Vec, + 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>, +} + +struct MergerSpec { + producer_r_fds: Vec, + 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::>(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, + edge_senders: Vec>, +) -> 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> = 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 = 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], + edge_senders: &[Option], + 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, + buf: &mut [u8], +) -> std::io::Result { + 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, + 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); + } + } +} diff --git a/02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs b/02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs new file mode 100644 index 0000000..09910df --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/pipelines.rs @@ -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 { + 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 = 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, + ) { + 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)> { + 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 { + 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 { + let g = self.inner.lock().await; + let mut v: Vec = 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 { + 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 + } +} diff --git a/02_ruway/shuma/sandbox/shuma-core/src/runtime.rs b/02_ruway/shuma/sandbox/shuma-core/src/runtime.rs new file mode 100644 index 0000000..adce389 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/runtime.rs @@ -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, 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) { + let mut to_restart: Vec = Vec::new(); + let mut to_enforce_kill: Vec = 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 = 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 = 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 = 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; + } + } + }); + } + } +} diff --git a/02_ruway/shuma/sandbox/shuma-core/src/stats.rs b/02_ruway/shuma/sandbox/shuma-core/src/stats.rs new file mode 100644 index 0000000..33ba9ff --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/stats.rs @@ -0,0 +1,210 @@ +//! Resource accounting por workspace. +//! +//! Dos fuentes: +//! - **Per-proc** (`/proc//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, + /// 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, + /// Tiempo CPU acumulado en microsegundos. `None` si no se pudo medir. + pub cpu_usec: Option, + /// %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, + /// 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 { + 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, + /// Límite de procesos declarado. + pub nproc_limit: Option, + /// Lista de violaciones detectadas (strings humano-legibles). + /// Empty = todo dentro de quota. + pub breaches: Vec, +} + +/// 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 = 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, + cpu_usec: u64, +} + +/// Lee `(rss_bytes, rss_peak_bytes, cpu_usec)` de `/proc//`. 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::().ok()).unwrap_or(0); + let stime = fields.get(12).and_then(|s| s.parse::().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 { + let mem = std::fs::read_to_string(cgroup_path.join("memory.current")) + .ok() + .and_then(|s| s.trim().parse::().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::().ok()) + .unwrap_or(0); + let peak = std::fs::read_to_string(cgroup_path.join("memory.peak")) + .ok() + .and_then(|s| s.trim().parse::().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"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-core/src/tests.rs b/02_ruway/shuma/sandbox/shuma-core/src/tests.rs new file mode 100644 index 0000000..7b25dba --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/tests.rs @@ -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; +} diff --git a/02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs b/02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs new file mode 100644 index 0000000..7832b0f --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-core/src/workspaces.rs @@ -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 { + 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 { + 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//` 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 { + let mut g = self.inner.lock().await; + let ws = g.workspaces.get_mut(&id)?; + let alive: Vec = 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> { + 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, + spec: WorkspaceSpec, + ) -> Result<(WorkspaceId, Vec), 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, + id: WorkspaceId, + spec: WorkspaceSpec, + ) -> Result<(WorkspaceId, Vec), 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 { + 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 { + 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 { + 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 = 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 = 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, + envp: Vec<(String, String)>, + ) -> Result { + 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, + envp: Vec<(String, String)>, + restart_on_failure: bool, + ) -> Result { + 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> { + 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 { + let g = self.inner.lock().await; + let Some(ws) = g.workspaces.get(&workspace) else { return Vec::new() }; + let mut out: Vec = 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 + } + +} diff --git a/02_ruway/shuma/sandbox/shuma-exec/Cargo.toml b/02_ruway/shuma/sandbox/shuma-exec/Cargo.toml new file mode 100644 index 0000000..7e3e0f7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-exec/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/sandbox/shuma-exec/LEEME.md b/02_ruway/shuma/sandbox/shuma-exec/LEEME.md new file mode 100644 index 0000000..5c99811 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-exec/LEEME.md @@ -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` diff --git a/02_ruway/shuma/sandbox/shuma-exec/README.md b/02_ruway/shuma/sandbox/shuma-exec/README.md new file mode 100644 index 0000000..447abc1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-exec/README.md @@ -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` diff --git a/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs b/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs new file mode 100644 index 0000000..4e667c3 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-exec/src/lib.rs @@ -0,0 +1,1269 @@ +//! `shuma-exec` — ejecución de comandos del shell con salida en streaming. +//! +//! Dos modos de ejecución, un mismo contrato de eventos: +//! +//! - [`Exec::Direct`] — brahman lanza y **conecta los procesos él mismo**: +//! un `Command` por etapa del pipe, los pipes cableados con descriptores +//! reales. Control total del árbol de procesos (matar todo el pipe de +//! un golpe). Es el modo preferido. +//! - [`Exec::Shell`] — delega a un shell externo (`bash -c ""`). +//! Reservado para sintaxis que el modo directo aún no absorbe (globs, +//! `$VAR`, redirecciones, `&&`). bash es **sólo un parser de sintaxis**, +//! no el ejecutor por defecto. +//! +//! **Captura acotada.** [`CommandSpec::capture_limit`] topa los bytes en +//! RAM; pasado el tope, o se **descarta** ([`RunEvent::Truncated`]) o se +//! **vuelca a un archivo** si hay [`CommandSpec::spill_path`] +//! ([`RunEvent::Spilled`]). En ambos casos el pipe se sigue drenando, así +//! el proceso no se bloquea. +//! +//! **Reproceso.** [`CommandSpec::stdin_data`] alimenta un texto por la +//! entrada estándar: reprocesa la salida capturada de un comando previo +//! sin volver a correr el original. + +#![forbid(unsafe_code)] + +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::fd::AsFd; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use nix::fcntl::{splice, SpliceFFlags}; +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; + +/// Una etapa del pipe en ejecución directa: un binario y sus argumentos +/// ya resueltos (sin comillas, sin metacaracteres). +#[derive(Debug, Clone)] +pub struct StageSpec { + pub program: String, + pub args: Vec, +} + +/// Cómo ejecutar. +#[derive(Debug, Clone)] +pub enum Exec { + /// Vía un shell externo — `program -c ""`. + Shell { line: String, program: String }, + /// Directo — brahman lanza y conecta cada etapa. + Direct { stages: Vec }, + /// Bajo un PTY (cross-platform vía `portable-pty`). Pensado para + /// comandos **TUI fullscreen** (vim, htop, less, claude code) que + /// detectan `isatty()` y rehúsan funcionar con pipes. Emite + /// [`RunEvent::Bytes`] crudos en vez de `Stdout(String)` para que + /// el frontend pueda alimentar un emulador vt100 propio. + Pty { + program: String, + args: Vec, + cols: u16, + rows: u16, + }, +} + +/// Qué ejecutar y con qué política de captura. +#[derive(Debug, Clone)] +pub struct CommandSpec { + pub exec: Exec, + pub cwd: String, + /// Tope de captura en bytes; `0` = sin límite. + pub capture_limit: usize, + /// Si está, la salida que excede el tope se vuelca a este archivo. + pub spill_path: Option, + /// Texto a alimentar por stdin — para reprocesar una salida previa. + pub stdin_data: Option, + /// Si `true`, en un pipe `Direct` se intercepta el stdout de **cada + /// etapa intermedia** (tee): además de alimentar a la siguiente, cada + /// línea se emite como [`RunEvent::StageStdout`]. Permite ver el stream + /// de cada etapa **en vivo**, sin re-ejecutar. Default `false` (sólo se + /// captura la salida de la última etapa, como siempre). + pub capture_stages: bool, +} + +impl CommandSpec { + /// Ejecución vía `bash -c ""`. + pub fn shell(line: impl Into, cwd: impl Into) -> Self { + Self { + exec: Exec::Shell { line: line.into(), program: "bash".into() }, + cwd: cwd.into(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + } + } + + /// Ejecución directa de un pipe de etapas. + pub fn direct(stages: Vec, cwd: impl Into) -> Self { + Self { + exec: Exec::Direct { stages }, + cwd: cwd.into(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + } + } + + /// Activa la captura por etapa (tee) en pipes directos (encadenable). + pub fn with_stage_capture(mut self) -> Self { + self.capture_stages = true; + self + } + + /// Fija el tope de captura en bytes (encadenable). + pub fn with_limit(mut self, bytes: usize) -> Self { + self.capture_limit = bytes; + self + } + + /// Vuelca la salida excedente a `path` en vez de descartarla. + pub fn with_spill(mut self, path: PathBuf) -> Self { + self.spill_path = Some(path); + self + } + + /// Alimenta `data` por la entrada estándar del proceso (encadenable). + pub fn with_stdin(mut self, data: impl Into) -> Self { + self.stdin_data = Some(data.into()); + self + } +} + +/// Un evento de la ejecución de un comando. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunEvent { + /// Una línea de salida estándar (de la última etapa del pipe). + Stdout(String), + /// Una línea de stdout de una etapa **intermedia** del pipe (tee). Sólo + /// se emite con `CommandSpec::capture_stages`. `stage` = índice 0-based + /// de la etapa que la produjo. El front la muestra en el desplegable de + /// esa etapa, sin re-ejecutar nada. + StageStdout { stage: usize, line: String }, + /// Una línea de salida de error. + Stderr(String), + /// Un chunk de bytes crudos del PTY (sólo bajo [`Exec::Pty`]). El + /// frontend debe alimentarlo a un emulador vt100 para renderizarlo; + /// puede traer secuencias de cursor movement, erase, OSC, etc. + Bytes(Vec), + /// La captura alcanzó su tope; lo que sigue se descarta. + Truncated, + /// La captura alcanzó su tope; el resto se vuelca al archivo dado. + Spilled(String), + /// El proceso terminó con este código de salida. + Exited(i32), + /// El proceso no pudo siquiera lanzarse. + Failed(String), +} + +impl RunEvent { + /// `true` si el evento cierra la ejecución (`Exited` o `Failed`). + pub fn is_terminal(&self) -> bool { + matches!(self, RunEvent::Exited(_) | RunEvent::Failed(_)) + } +} + +/// Asa de un comando en ejecución. El consumidor la conserva y drena sus +/// eventos cuando le conviene. +pub struct RunHandle { + rx: Receiver, + finished: bool, + /// Los procesos, compartidos con el hilo coordinador para poder + /// matarlos — todas las etapas de un pipe directo. Vacío para + /// runs PTY. + children: Arc>>, + /// PIDs vistos de runs PTY (los gestiona `portable-pty`; no son + /// `std::process::Child`). Se usan para enviarles señales con + /// `nix::sys::signal::kill`. + pty_pids: Arc>>, + /// Canal opcional para escribir bytes en el stdin del proceso. + /// Sólo está cableado en modo PTY; en los otros modos los sends + /// no llegan a nadie (el receptor se cae al instante). + stdin_tx: Sender>, + /// PTY master vivo — sólo en modo `Exec::Pty`. Permite resize en + /// caliente cuando el panel cambia de tamaño. El coordinador del + /// PTY lo rellena tras crear el `pair` y lo conserva hasta el exit + /// del child (drop al final). + pty_master: Arc>>>, +} + +/// Asa "fría" de un `RunHandle` que **sólo** sirve para matar el comando. +/// No comparte el lock principal de eventos, así que se puede usar desde +/// otro hilo/task aún cuando el dueño del `RunHandle` esté bloqueado en +/// `next_event()`. Cloneable. +#[derive(Clone)] +pub struct Killer { + children: Arc>>, + pty_pids: Arc>>, +} + +impl Killer { + /// Manda SIGKILL a todas las etapas vivas del comando. No hace nada + /// si ya terminaron. La señal va a todo el **grupo de procesos** de + /// cada etapa — con `bash -c "sleep 30"`, esto mata bash *y* el + /// sleep hijo (mismo pgid; `spawn_shell` arma cada child con + /// `process_group(0)`). + pub fn kill(&self) { + self.signal(nix::sys::signal::Signal::SIGKILL); + // Fallback: además de la señal, llamamos a `kill()` del Child + // para que `wait()` cosechée el exit status sin colgarse (el + // `kill` de std manda SIGKILL al PID directo y no falla si el + // proceso ya murió). + if let Ok(mut guard) = self.children.lock() { + for c in guard.iter_mut() { + let _ = c.kill(); + } + } + } + + /// PIDs de las etapas que aún consider vivas el coordinador. Puede + /// estar vacío durante una micro-ventana entre `run()` y el spawn + /// real — no es un bug, sólo refleja la realidad del scheduling. + pub fn pids(&self) -> Vec { + let mut out = Vec::new(); + if let Ok(g) = self.children.lock() { + out.extend(g.iter().map(|c| c.id())); + } + if let Ok(g) = self.pty_pids.lock() { + out.extend(g.iter().copied()); + } + out + } + + /// SIGTERM — el "kill educado" (Ctrl-C estándar). El proceso suele + /// limpiar antes de morir. Devuelve `true` si llegó a al menos una + /// etapa viva. + pub fn term(&self) -> bool { + self.signal(nix::sys::signal::Signal::SIGTERM) + } + + /// SIGSTOP — el proceso pasa a estado "stopped"; no consume CPU y + /// no produce salida hasta recibir SIGCONT. Útil para "pausar" un + /// `tail -f` o un build ruidoso sin perderlo. + pub fn stop(&self) -> bool { + self.signal(nix::sys::signal::Signal::SIGSTOP) + } + + /// SIGCONT — reanuda un proceso parado con [`Killer::stop`]. + pub fn cont(&self) -> bool { + self.signal(nix::sys::signal::Signal::SIGCONT) + } + + fn signal(&self, sig: nix::sys::signal::Signal) -> bool { + let pids = self.pids(); + let mut delivered = false; + for pid in pids { + let target = nix::unistd::Pid::from_raw(pid as i32); + // `killpg` busca el grupo cuyo pgid coincide con `pid`: + // como cada child se lanzó con `process_group(0)`, el child + // es líder del grupo y `pgid == pid`. Matar el grupo abarca + // cualquier proceso que el child hubiese forkado. + if nix::sys::signal::killpg(target, sig).is_ok() { + delivered = true; + } else if nix::sys::signal::kill(target, sig).is_ok() { + // Fallback: si el child no es líder de grupo (PTY usa + // `portable-pty`, que no garantiza `process_group(0)`), + // mandamos al PID directo. + delivered = true; + } + } + delivered + } +} + +impl RunHandle { + /// Mata todos los procesos del comando. No hace nada si ya terminaron. + pub fn kill(&self) { + if let Ok(mut guard) = self.children.lock() { + for c in guard.iter_mut() { + let _ = c.kill(); + } + } + } + + /// Asa cloneable que sólo permite matar el comando — útil para usar + /// `kill()` desde otra tarea sin tocar el lock que tiene el reader. + pub fn killer(&self) -> Killer { + Killer { + children: Arc::clone(&self.children), + pty_pids: Arc::clone(&self.pty_pids), + } + } + + /// Escribe bytes en el stdin del proceso. Sólo tiene efecto bajo + /// [`Exec::Pty`] — el modo TUI cablea un writer thread que recibe + /// estos bytes y los reenvía al PTY master. En los otros modos, el + /// send se descarta (no hay listener) y devuelve `false`. + pub fn write_input(&self, bytes: Vec) -> bool { + self.stdin_tx.send(bytes).is_ok() + } + + /// Reescala el PTY. Sólo aplica bajo [`Exec::Pty`]: lockea el + /// master vivo y llama a `MasterPty::resize`. Devuelve `false` + /// silenciosamente si no hay PTY (modos Shell/Direct), el master + /// no se publicó todavía, o el SO devuelve error. + pub fn resize(&self, rows: u16, cols: u16) -> bool { + let Ok(mut guard) = self.pty_master.lock() else { + return false; + }; + let Some(master) = guard.as_mut() else { + return false; + }; + master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .is_ok() + } + + /// Próximo evento, bloqueando hasta que llegue. `None` cuando el + /// proceso terminó (ya se emitió `Exited`/`Failed`) o el canal se + /// cerró. Pensado para puentes sync→async (el daemon lo usa para + /// re-emitir cada evento como un frame del protocolo). + pub fn next_event(&mut self) -> Option { + if self.finished { + return None; + } + match self.rx.recv() { + Ok(ev) => { + if ev.is_terminal() { + self.finished = true; + } + Some(ev) + } + Err(_) => { + self.finished = true; + None + } + } + } + + /// Drena todos los eventos disponibles ahora mismo, sin bloquear. + pub fn try_events(&mut self) -> Vec { + let mut out = Vec::new(); + loop { + match self.rx.try_recv() { + Ok(ev) => { + if ev.is_terminal() { + self.finished = true; + } + out.push(ev); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + self.finished = true; + break; + } + } + } + out + } + + /// Bloquea hasta que el proceso termine y devuelve todos sus eventos. + pub fn wait_all(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(ev) = self.rx.recv() { + let terminal = ev.is_terminal(); + out.push(ev); + if terminal { + self.finished = true; + } + } + self.finished = true; + out + } + + /// `true` si ya se observó el evento terminal. + pub fn is_finished(&self) -> bool { + self.finished + } + + /// Asa "fría" para controlar el PTY (stdin + resize) desde otra + /// tarea/hilo sin tocar el lock de eventos — espejo de [`Killer`] + /// para el lado de entrada. La usa el daemon: el `RunHandle` se mueve + /// a un hilo-puente que bloquea en `next_event()`, mientras la tarea + /// async conserva este `PtyControl` para reenviar las teclas y los + /// resize que llegan del cliente remoto. + pub fn pty_control(&self) -> PtyControl { + PtyControl { + stdin_tx: self.stdin_tx.clone(), + pty_master: Arc::clone(&self.pty_master), + } + } +} + +/// Asa cloneable de **control de entrada** de un run PTY: escribe stdin y +/// reescala, sin compartir el lock de eventos del [`RunHandle`]. Igual que +/// con [`RunHandle::write_input`]/[`RunHandle::resize`], las operaciones +/// son no-op fuera de modo [`Exec::Pty`]. +#[derive(Clone)] +pub struct PtyControl { + stdin_tx: Sender>, + pty_master: Arc>>>, +} + +impl PtyControl { + /// Escribe bytes en el stdin del PTY. Ver [`RunHandle::write_input`]. + pub fn write_input(&self, bytes: Vec) -> bool { + self.stdin_tx.send(bytes).is_ok() + } + + /// Reescala el PTY. Ver [`RunHandle::resize`]. + pub fn resize(&self, rows: u16, cols: u16) -> bool { + let Ok(mut guard) = self.pty_master.lock() else { + return false; + }; + let Some(master) = guard.as_mut() else { + return false; + }; + master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .is_ok() + } +} + +/// Vuelca el resto de un pipe a un archivo con **copia cero** (`splice`): +/// los bytes van de pipe a archivo sin pasar por espacio de usuario. +fn spill_rest(reader: &mut BufReader, path: &Path, first_line: &str) { + let Ok(file) = File::create(path) else { + return; + }; + let mut file = file; + // La línea que cruzó el tope y lo ya bufereado van primero… + let _ = file.write_all(first_line.as_bytes()); + let buffered: Vec = reader.buffer().to_vec(); + let _ = file.write_all(&buffered); + reader.consume(buffered.len()); + // …y el resto del pipe se mueve con `splice`, kernel a kernel. + loop { + match splice(reader.get_ref(), None, &file, None, 1 << 20, SpliceFFlags::empty()) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } +} + +/// Lanza un hilo lector de un flujo, con captura acotada. Pasado el tope: +/// si hay `spill`, el resto se vuelca al archivo con `splice` (copia +/// cero); si no, se descarta. En ambos casos el pipe se **sigue +/// drenando** — el proceso nunca se bloquea. +#[allow(clippy::too_many_arguments)] +fn spawn_reader( + stream: R, + tx: Sender, + make: fn(String) -> RunEvent, + limit: usize, + counter: Arc, + announced: Arc, + spill: Option, +) -> JoinHandle<()> { + std::thread::spawn(move || { + let mut reader = BufReader::new(stream); + let mut buf = String::new(); + loop { + buf.clear(); + let n = match reader.read_line(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => n, + Err(_) => break, + }; + let total = counter.fetch_add(n, Ordering::Relaxed) + n; + if limit != 0 && total > limit { + let first = !announced.swap(true, Ordering::Relaxed); + match &spill { + Some(path) => { + if first { + let _ = tx.send(RunEvent::Spilled(path.display().to_string())); + } + spill_rest(&mut reader, path, &buf); + break; // splice se llevó el resto + } + None => { + if first { + let _ = tx.send(RunEvent::Truncated); + } + continue; // descarta, pero sigue drenando + } + } + } + let line = buf.trim_end_matches(['\n', '\r']).to_string(); + if tx.send(make(line)).is_err() { + break; + } + } + }) +} + +/// Resultado de lanzar los procesos: lo que el coordinador necesita. +struct Spawned { + children: Vec, + stdin: Option, + stdout: Option, + stderrs: Vec, + /// Etapas intermedias a interceptar (solo con `capture_stages`). Vacío + /// en el caso normal. + stage_tees: Vec, +} + +/// Una etapa intermedia cuyo stdout interceptamos: el coordinador lee +/// `stdout`, reenvía los bytes a `sink` (el stdin de la etapa siguiente) y +/// emite cada línea como [`RunEvent::StageStdout`]. +struct StageTee { + stage: usize, + stdout: std::process::ChildStdout, + sink: std::fs::File, +} + +/// Lanza un único proceso shell (`program -c ""`). +fn spawn_shell(line: &str, program: &str, cwd: &str, want_stdin: bool) -> std::io::Result { + let mut child = Command::new(program) + .arg("-c") + .arg(line) + .current_dir(cwd) + .stdin(if want_stdin { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // Nuevo grupo de procesos: con `bash -c "sleep 30"` el bash se + // forka a un sleep hijo; matar al bash sólo no alcanza al sleep. + // Con el grupo, `killpg(pid, SIG)` derriba a todo el subárbol. + .process_group(0) + .spawn()?; + let stdin = child.stdin.take(); + let stdout = child.stdout.take(); + let stderrs = child.stderr.take().into_iter().collect(); + Ok(Spawned { children: vec![child], stdin, stdout, stderrs, stage_tees: vec![] }) +} + +/// Lanza un pipe de etapas conectándolas con descriptores reales. +fn spawn_direct( + stages: &[StageSpec], + cwd: &str, + want_stdin: bool, + capture_stages: bool, +) -> std::io::Result { + if stages.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "pipe vacío", + )); + } + let n = stages.len(); + let mut children: Vec = Vec::with_capacity(n); + let mut stage_tees: Vec = Vec::new(); + // Qué alimenta el stdin de la etapa actual (i>0): el stdout de la + // anterior (directo) o el read-end de un pipe de tee (capturado). + let mut next_stdin: Option = None; + + for (i, st) in stages.iter().enumerate() { + let mut cmd = Command::new(&st.program); + cmd.args(&st.args) + .current_dir(cwd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if i == 0 { + cmd.stdin(if want_stdin { Stdio::piped() } else { Stdio::null() }); + // Primera etapa abre su propio grupo de procesos; las demás + // se enganchan en el mismo (process_group(0) hereda el pgid + // del padre — pero el padre somos nosotros, no la etapa 0). + // En la práctica `setpgid` del kernel respeta sólo lo que + // pedimos: las etapas 1.. quedan en pgid de la 0 si las + // forkamos con CLONE_PARENT_SETTID; con Command no tenemos + // ese control fino, así que cada stage queda en su propio + // pgid. El Killer cubre todas las etapas igual porque + // mantiene los PIDs/Childs por separado. + cmd.process_group(0); + } else { + // La etapa anterior alimenta a ésta (stdout directo o tee). + cmd.stdin(next_stdin.take().expect("stdin de etapa previa")); + cmd.process_group(0); + } + match cmd.spawn() { + Ok(mut child) => { + if i + 1 < n { + let stdout = child.stdout.take().expect("stdout de etapa intermedia"); + if capture_stages { + // Tee: pipe propio. La etapa siguiente lee del read-end; + // un hilo del coordinador reenvía stage[i].stdout al + // write-end y captura cada línea como StageStdout. + // `O_CLOEXEC`: estos fds NO deben heredarse a las etapas + // que spawneamos después. Si una etapa heredara el + // write-end del pipe de tee, su lado lector nunca vería + // EOF y se colgaría esperando más entrada (deadlock). El + // dup2 a fd 0 de la etapa siguiente lo hace std (limpia + // CLOEXEC en el fd 0 resultante), así que su stdin queda bien. + let (rd, wr) = nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC) + .map_err(|e| std::io::Error::other(format!("pipe tee: {e}")))?; + next_stdin = Some(Stdio::from(std::fs::File::from(rd))); + stage_tees.push(StageTee { + stage: i, + stdout, + sink: std::fs::File::from(wr), + }); + } else { + next_stdin = Some(Stdio::from(stdout)); + } + } + children.push(child); + } + Err(e) => { + // Si una etapa no arranca, se matan las ya lanzadas. + for mut c in children { + let _ = c.kill(); + } + return Err(std::io::Error::new( + e.kind(), + format!("{}: {e}", st.program), + )); + } + } + } + + let stdin = children.first_mut().and_then(|c| c.stdin.take()); + let stdout = children.last_mut().and_then(|c| c.stdout.take()); + let stderrs = children.iter_mut().filter_map(|c| c.stderr.take()).collect(); + Ok(Spawned { children, stdin, stdout, stderrs, stage_tees }) +} + +/// Hilo de tee de una etapa intermedia: lee su stdout, lo reenvía a `sink` +/// (stdin de la etapa siguiente) y emite cada línea como `StageStdout`. Al +/// EOF cierra `sink` (drop) para que la etapa siguiente vea fin de entrada. +fn tee_pump( + stage: usize, + mut stdout: std::process::ChildStdout, + mut sink: std::fs::File, + tx: Sender, +) { + let mut buf = [0u8; 8192]; + let mut line: Vec = Vec::new(); + loop { + let n = match stdout.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + let chunk = &buf[..n]; + // Reenviar a la etapa siguiente (si murió, igual seguimos drenando + // para no bloquear a la etapa actual). + let _ = sink.write_all(chunk); + // Capturar por línea para el desplegable de la etapa. + for &b in chunk { + if b == b'\n' { + let s = String::from_utf8_lossy(&line).into_owned(); + let _ = tx.send(RunEvent::StageStdout { stage, line: s }); + line.clear(); + } else { + line.push(b); + } + } + } + if !line.is_empty() { + let s = String::from_utf8_lossy(&line).into_owned(); + let _ = tx.send(RunEvent::StageStdout { stage, line: s }); + } +} + +/// Lanza `spec` y devuelve un [`RunHandle`] desde el que drenar la +/// salida. La función vuelve de inmediato: el proceso corre en hilos. +pub fn run(spec: &CommandSpec) -> RunHandle { + let (tx, rx) = mpsc::channel(); + let (stdin_tx, stdin_rx) = mpsc::channel::>(); + let spec = spec.clone(); + let cell: Arc>> = Arc::new(Mutex::new(Vec::new())); + let pty_pids: Arc>> = Arc::new(Mutex::new(Vec::new())); + let pty_master: Arc>>> = + Arc::new(Mutex::new(None)); + let cell_thread = Arc::clone(&cell); + let pty_pids_thread = Arc::clone(&pty_pids); + let pty_master_thread = Arc::clone(&pty_master); + + // Modo PTY: ruta separada — el proceso corre bajo un pseudo-terminal + // (cross-platform via `portable-pty`), los bytes crudos se emiten + // como [`RunEvent::Bytes`] para que el frontend los pase por su + // emulador vt100, y el frontend escribe en stdin con + // [`RunHandle::write_input`]. + if let Exec::Pty { program, args, cols, rows } = &spec.exec { + let program = program.clone(); + let args = args.clone(); + let cols = *cols; + let rows = *rows; + let cwd = spec.cwd.clone(); + std::thread::spawn(move || { + spawn_pty_thread( + &program, + &args, + &cwd, + cols, + rows, + tx, + stdin_rx, + pty_pids_thread, + pty_master_thread, + ); + }); + return RunHandle { + rx, + finished: false, + children: cell, + pty_pids, + stdin_tx, + pty_master, + }; + } + + std::thread::spawn(move || { + let want_stdin = spec.stdin_data.is_some(); + let spawned = match &spec.exec { + Exec::Shell { line, program } => { + spawn_shell(line, program, &spec.cwd, want_stdin) + } + Exec::Direct { stages } => { + spawn_direct(stages, &spec.cwd, want_stdin, spec.capture_stages) + } + Exec::Pty { .. } => unreachable!("Pty se maneja antes"), + }; + let Spawned { children, stdin, stdout, stderrs, stage_tees } = match spawned { + Ok(s) => s, + Err(e) => { + let _ = tx.send(RunEvent::Failed(e.to_string())); + return; + } + }; + + // Alimenta stdin (reproceso) en su propio hilo. + if let (Some(data), Some(mut sink)) = (spec.stdin_data.clone(), stdin) { + std::thread::spawn(move || { + let _ = sink.write_all(data.as_bytes()); + }); + } + + // Comparte los procesos para que `kill` los alcance. + if let Ok(mut g) = cell_thread.lock() { + *g = children; + } + + // Tee de etapas intermedias (solo con capture_stages): un hilo por + // etapa que reenvía su stdout a la siguiente y emite StageStdout. + // Guardamos los handles para joinearlos antes de `Exited` (si no, un + // StageStdout tardío se perdería tras cerrar el run). + let mut tee_handles: Vec> = Vec::new(); + for tee in stage_tees { + let txc = tx.clone(); + tee_handles + .push(std::thread::spawn(move || tee_pump(tee.stage, tee.stdout, tee.sink, txc))); + } + + // Captura acotada: contador y aviso compartidos por todos los + // lectores. El volcado a archivo se aplica sólo a stdout (el + // contenido principal); stderr excedente se descarta. + let counter = Arc::new(AtomicUsize::new(0)); + let announced = Arc::new(AtomicBool::new(false)); + let limit = spec.capture_limit; + + let mut readers: Vec> = Vec::new(); + if let Some(s) = stdout { + readers.push(spawn_reader( + s, + tx.clone(), + RunEvent::Stdout, + limit, + Arc::clone(&counter), + Arc::clone(&announced), + spec.spill_path.clone(), + )); + } + for s in stderrs { + readers.push(spawn_reader( + s, + tx.clone(), + RunEvent::Stderr, + limit, + Arc::clone(&counter), + Arc::clone(&announced), + None, + )); + } + for h in readers { + let _ = h.join(); + } + // Joinear los tees garantiza que todos los StageStdout salgan antes + // del Exited (drenaje completo de las etapas intermedias). + for h in tee_handles { + let _ = h.join(); + } + + // Cosecha todas las etapas; el código de salida es el de la última. + let code = { + let mut g = cell_thread.lock().expect("children lock"); + let mut last = -1; + for c in g.iter_mut() { + last = c.wait().ok().and_then(|s| s.code()).unwrap_or(-1); + } + last + }; + let _ = tx.send(RunEvent::Exited(code)); + }); + + RunHandle { + rx, + finished: false, + children: cell, + pty_pids, + stdin_tx, + pty_master, + } +} + +/// Coordinador del modo PTY: aloja un PTY de tamaño `cols`×`rows`, +/// lanza el comando bajo él, y mantiene tres flujos: +/// +/// - Reader thread: lee chunks de hasta 4 KiB del master del PTY y los +/// emite como `RunEvent::Bytes`. Termina cuando el child cierra el +/// slave (EOF). +/// - Writer thread: bloquea en `stdin_rx` y reenvía bytes al master +/// del PTY (lo que el frontend manda como input crudo). +/// - Esta función espera al child y emite `RunEvent::Exited(code)`. +fn spawn_pty_thread( + program: &str, + args: &[String], + cwd: &str, + cols: u16, + rows: u16, + tx: Sender, + stdin_rx: Receiver>, + pty_pids: Arc>>, + pty_master_slot: Arc>>>, +) { + let pty_system = native_pty_system(); + let pair = match pty_system.openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) { + Ok(p) => p, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("openpty: {e}"))); + return; + } + }; + let mut cmd = CommandBuilder::new(program); + for a in args { + cmd.arg(a); + } + cmd.cwd(cwd); + // Heurística estándar: TUIs leen `TERM` para decidir capacidad de + // colores y movimiento. xterm-256color es el lcm más amplio. + cmd.env("TERM", "xterm-256color"); + let mut child = match pair.slave.spawn_command(cmd) { + Ok(c) => c, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("spawn: {e}"))); + return; + } + }; + // El slave fd no se necesita más en el padre; cerrarlo aquí evita + // que el child quede sin EOF cuando cierra su lado. + drop(pair.slave); + + if let Some(pid) = child.process_id() { + if let Ok(mut g) = pty_pids.lock() { + g.push(pid); + } + } + + let mut reader = match pair.master.try_clone_reader() { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("try_clone_reader: {e}"))); + return; + } + }; + let mut writer = match pair.master.take_writer() { + Ok(w) => w, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("take_writer: {e}"))); + return; + } + }; + + // Publica el master vivo: el `RunHandle` lo lockea para resize en + // caliente. `take_writer`/`try_clone_reader` ya retornaron handles + // independientes; el master se mantiene como dueño del PTY. + if let Ok(mut slot) = pty_master_slot.lock() { + *slot = Some(pair.master); + } + + let tx_reader = tx.clone(); + let reader_thread = std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, // child cerró el slave + Ok(n) => { + if tx_reader.send(RunEvent::Bytes(buf[..n].to_vec())).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let writer_thread = std::thread::spawn(move || { + while let Ok(bytes) = stdin_rx.recv() { + if writer.write_all(&bytes).is_err() { + break; + } + let _ = writer.flush(); + } + }); + + let code = match child.wait() { + Ok(s) => s.exit_code() as i32, + Err(_) => -1, + }; + // El master se dropea ahora → reader ve EOF y termina. Esperamos al + // reader para que no se pierdan bytes finales. + if let Ok(mut slot) = pty_master_slot.lock() { + *slot = None; + } + let _ = reader_thread.join(); + // El writer thread sale solo cuando el stdin_tx se dropee del lado + // del frontend; no lo joineamos sincrónicamente. + drop(writer_thread); + let _ = tx.send(RunEvent::Exited(code)); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Ejecución directa de un único programa. + fn direct(program: &str, args: &[&str]) -> CommandSpec { + CommandSpec::direct( + vec![StageSpec { + program: program.into(), + args: args.iter().map(|s| s.to_string()).collect(), + }], + ".", + ) + } + + /// Pipe directo de varias etapas. + fn pipe(stages: &[(&str, &[&str])]) -> CommandSpec { + CommandSpec::direct( + stages + .iter() + .map(|(p, a)| StageSpec { + program: p.to_string(), + args: a.iter().map(|s| s.to_string()).collect(), + }) + .collect(), + ".", + ) + } + + fn stdout_of(events: Vec) -> Vec { + events + .into_iter() + .filter_map(|e| match e { + RunEvent::Stdout(l) => Some(l), + _ => None, + }) + .collect() + } + + #[test] + fn direct_runs_a_single_program() { + let mut h = run(&direct("echo", &["hola", "mundo"])); + let events = h.wait_all(); + assert!(events.contains(&RunEvent::Stdout("hola mundo".into()))); + assert!(events.contains(&RunEvent::Exited(0))); + } + + #[test] + fn direct_wires_a_pipeline_itself() { + // printf … | sort — brahman conecta los procesos, sin shell. + let mut h = run(&pipe(&[ + ("printf", &["b\\na\\nc\\n"]), + ("sort", &[]), + ])); + assert_eq!(stdout_of(h.wait_all()), vec!["a", "b", "c"]); + } + + #[test] + fn direct_three_stage_pipeline() { + let mut h = run(&pipe(&[ + ("printf", &["3\\n1\\n2\\n1\\n"]), + ("sort", &[]), + ("uniq", &[]), + ])); + assert_eq!(stdout_of(h.wait_all()), vec!["1", "2", "3"]); + } + + fn stage_lines(events: &[RunEvent], stage: usize) -> Vec { + events + .iter() + .filter_map(|e| match e { + RunEvent::StageStdout { stage: s, line } if *s == stage => Some(line.clone()), + _ => None, + }) + .collect() + } + + // El supuesto "deadlock por write-end huérfano" era en realidad el error de + // build: se pidió la feature `fcntl` de nix (inexistente en 0.29 — `OFlag` + // vive bajo `fs`, ya habilitada), así que el crate ni compilaba y el fallo + // se confundió con un cuelgue. Con pipe2(O_CLOEXEC) ningún hijo hereda el + // write-end del tee y la etapa siguiente ve EOF en cuanto la anterior cierra + // su stdout; verificado estable (5/5). + #[test] + fn capture_stages_intercepts_each_stage_stdout() { + // printf "hello" | tr a-z A-Z | rev → etapa0="hello", etapa1="HELLO", + // salida final (rev) = "OLLEH". El tee captura las intermedias EN VIVO + // sin re-ejecutar, que es lo que el desplegable necesita por etapa. + let spec = pipe(&[ + ("printf", &["hello\\n"]), + ("tr", &["a-z", "A-Z"]), + ("rev", &[]), + ]) + .with_stage_capture(); + let events = run(&spec).wait_all(); + + assert_eq!(stage_lines(&events, 0), vec!["hello"], "etapa 0 (printf)"); + assert_eq!(stage_lines(&events, 1), vec!["HELLO"], "etapa 1 (tr)"); + // La última etapa sigue saliendo por Stdout normal. + assert_eq!(stdout_of(events), vec!["OLLEH"]); + } + + #[test] + fn without_capture_stages_there_are_no_stage_events() { + // El comportamiento por defecto no cambia: sólo la salida final. + let events = run(&pipe(&[("printf", &["x\\n"]), ("cat", &[])])).wait_all(); + assert!( + !events + .iter() + .any(|e| matches!(e, RunEvent::StageStdout { .. })), + "sin with_stage_capture no debe haber StageStdout" + ); + assert_eq!(stdout_of(events), vec!["x"]); + } + + #[test] + fn direct_nonzero_exit_is_the_last_stage() { + let mut h = run(&direct("false", &[])); + assert!(h.wait_all().contains(&RunEvent::Exited(1))); + } + + #[test] + fn direct_missing_program_fails_gracefully() { + let mut h = run(&direct("no-existe-binario-xyz", &[])); + let events = h.wait_all(); + assert!(matches!(events.first(), Some(RunEvent::Failed(_)))); + } + + #[test] + fn shell_mode_still_works_for_complex_syntax() { + let mut h = run(&CommandSpec { + exec: Exec::Shell { line: "echo $((2 + 3))".into(), program: "sh".into() }, + ..CommandSpec::shell("", ".") + }); + assert!(h.wait_all().contains(&RunEvent::Stdout("5".into()))); + } + + #[test] + fn capture_limit_truncates_but_process_finishes() { + let mut h = run(&direct("seq", &["1", "20000"]).with_limit(400)); + let events = h.wait_all(); + assert!(events.contains(&RunEvent::Truncated)); + assert!(events.contains(&RunEvent::Exited(0))); + assert!(stdout_of(events).len() < 20000); + } + + #[test] + fn spill_writes_overflow_to_a_file() { + let path = std::env::temp_dir() + .join(format!("shuma-exec-spill-{}.log", std::process::id())); + let _ = std::fs::remove_file(&path); + let mut h = run(&direct("seq", &["1", "5000"]) + .with_limit(200) + .with_spill(path.clone())); + let events = h.wait_all(); + assert!(events.iter().any(|e| matches!(e, RunEvent::Spilled(_)))); + assert!(events.contains(&RunEvent::Exited(0))); + // El archivo de volcado existe y tiene contenido. + let spilled = std::fs::read_to_string(&path).unwrap_or_default(); + assert!(spilled.contains("5000"), "la cola se volcó al archivo"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn stdin_data_reprocessed_by_a_filter() { + let mut h = run(&direct("grep", &["beta"]).with_stdin("alfa\nbeta\nbetabel\ngamma")); + assert_eq!(stdout_of(h.wait_all()), vec!["beta", "betabel"]); + } + + #[test] + fn kill_stops_a_long_running_pipeline() { + let mut h = run(&pipe(&[("sleep", &["30"]), ("cat", &[])])); + std::thread::sleep(std::time::Duration::from_millis(250)); + h.kill(); + let events = h.wait_all(); + assert!(events.last().map(|e| e.is_terminal()).unwrap_or(false)); + } + + #[test] + fn terminal_event_detection() { + assert!(RunEvent::Exited(0).is_terminal()); + assert!(!RunEvent::Truncated.is_terminal()); + assert!(!RunEvent::Spilled("x".into()).is_terminal()); + } + + #[test] + fn pty_run_emits_bytes_for_a_tty_aware_command() { + // `tty` imprime el path del terminal cuando se le da uno, o + // "not a tty" cuando se le pipea. Bajo PTY debe imprimir un + // path con `/dev/pts/` o `/dev/ptmx` (depende del SO). + let spec = CommandSpec { + exec: Exec::Pty { + program: "tty".into(), + args: vec![], + cols: 80, + rows: 24, + }, + cwd: ".".into(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + }; + let mut h = run(&spec); + let mut bytes_seen = Vec::::new(); + let mut exit = -999; + while let Some(ev) = h.next_event() { + match ev { + RunEvent::Bytes(b) => bytes_seen.extend(b), + RunEvent::Exited(c) => exit = c, + _ => {} + } + } + assert_eq!(exit, 0, "tty exit 0 bajo PTY (bytes='{}')", + String::from_utf8_lossy(&bytes_seen)); + let out = String::from_utf8_lossy(&bytes_seen); + // `tty` emite el path del slave + \r\n (los PTY añaden \r). + assert!( + out.contains("/dev/pts/") || out.contains("/dev/ptmx") || out.contains("/dev/tty"), + "tty output inesperado: {out:?}" + ); + } + + #[test] + fn pty_write_input_reaches_the_child_stdin() { + // `cat` bajo PTY refleja lo que le escribamos por stdin (en + // modo PTY, cada caracter va con echo encendido por defecto). + // Le mandamos "hola\n" y esperamos verlo en los bytes de + // salida. Después cerramos el stdin (drop del Sender) y + // mandamos Ctrl-D (0x04) para que cat termine. + let spec = CommandSpec { + exec: Exec::Pty { + program: "cat".into(), + args: vec![], + cols: 80, + rows: 24, + }, + cwd: ".".into(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + }; + let mut h = run(&spec); + // Cat necesita un instante para que su slave arranque y abra stdin. + std::thread::sleep(std::time::Duration::from_millis(150)); + assert!(h.write_input(b"hola\n".to_vec())); + // Ctrl-D para cerrar EOF en modo cooked. + std::thread::sleep(std::time::Duration::from_millis(100)); + assert!(h.write_input(b"\x04".to_vec())); + let mut bytes_seen = Vec::::new(); + let started = std::time::Instant::now(); + while let Some(ev) = h.next_event() { + if started.elapsed() > std::time::Duration::from_secs(5) { + panic!("cat no terminó en 5s tras Ctrl-D"); + } + if let RunEvent::Bytes(b) = ev { + bytes_seen.extend(b); + } + } + let out = String::from_utf8_lossy(&bytes_seen); + assert!(out.contains("hola"), "cat no echó 'hola': {out:?}"); + } + + #[test] + fn pty_resize_changes_dimensions_in_running_child() { + // Lanzamos `bash` bajo PTY a 80×24, esperamos que el slave + // tenga las dims iniciales, hacemos resize, y verificamos que + // el child ve el nuevo tamaño tras la señal SIGWINCH. + let spec = CommandSpec { + exec: Exec::Pty { + program: "bash".into(), + args: vec!["-c".into(), "stty size; sleep 0.3; stty size".into()], + cols: 80, + rows: 24, + }, + cwd: ".".into(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + }; + let mut h = run(&spec); + // Esperar a que el master se publique (race con el coordinador). + std::thread::sleep(std::time::Duration::from_millis(100)); + assert!(h.resize(40, 132), "resize debería aplicarse"); + let mut bytes = Vec::::new(); + while let Some(ev) = h.next_event() { + if let RunEvent::Bytes(b) = ev { + bytes.extend(b); + } + } + let out = String::from_utf8_lossy(&bytes); + // El primer `stty size` muestra 24 80; el segundo debe mostrar + // las dimensiones nuevas tras el resize (40 132). + assert!( + out.contains("40 132"), + "stty size tras resize no muestra 40 132: {out:?}" + ); + } + + #[test] + fn killer_can_stop_and_continue_a_process() { + // `sleep 30` se para con SIGSTOP, se reanuda con SIGCONT y + // luego se mata con SIGTERM. El test acaba en <1s aunque el + // sleep nominal sea de 30s. + let h = run(&direct("sleep", &["30"])); + let killer = h.killer(); + // Esperar a que aparezca el PID (el coordinador rellena el Vec + // tras el spawn — micro-delay). + let mut tries = 0; + while killer.pids().is_empty() && tries < 100 { + std::thread::sleep(std::time::Duration::from_millis(10)); + tries += 1; + } + assert!(!killer.pids().is_empty(), "pid no apareció"); + assert!(killer.stop(), "SIGSTOP no llegó"); + assert!(killer.cont(), "SIGCONT no llegó"); + assert!(killer.term(), "SIGTERM no llegó"); + // El test no se cuelga gracias al term(). + } +} diff --git a/02_ruway/shuma/sandbox/shuma-history/Cargo.toml b/02_ruway/shuma/sandbox/shuma-history/Cargo.toml new file mode 100644 index 0000000..d4dc8e8 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-history/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/sandbox/shuma-history/LEEME.md b/02_ruway/shuma/sandbox/shuma-history/LEEME.md new file mode 100644 index 0000000..6c4df34 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-history/LEEME.md @@ -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) diff --git a/02_ruway/shuma/sandbox/shuma-history/README.md b/02_ruway/shuma/sandbox/shuma-history/README.md new file mode 100644 index 0000000..7d876f1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-history/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) diff --git a/02_ruway/shuma/sandbox/shuma-history/src/lib.rs b/02_ruway/shuma/sandbox/shuma-history/src/lib.rs new file mode 100644 index 0000000..90e5b7f --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-history/src/lib.rs @@ -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, + /// 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, +} + +impl Entry { + /// Construye una entrada nueva con la línea y el cwd; resto a vacío. + pub fn new(line: impl Into, cwd: impl Into, 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, + 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 { + 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) -> io::Result { + 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::(&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 { + 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, 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); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-infer/Cargo.toml b/02_ruway/shuma/sandbox/shuma-infer/Cargo.toml new file mode 100644 index 0000000..2e0939c --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-infer/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/sandbox/shuma-infer/LEEME.md b/02_ruway/shuma/sandbox/shuma-infer/LEEME.md new file mode 100644 index 0000000..fd25c86 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-infer/LEEME.md @@ -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) diff --git a/02_ruway/shuma/sandbox/shuma-infer/README.md b/02_ruway/shuma/sandbox/shuma-infer/README.md new file mode 100644 index 0000000..f5de26a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-infer/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) diff --git a/02_ruway/shuma/sandbox/shuma-infer/src/lib.rs b/02_ruway/shuma/sandbox/shuma-infer/src/lib.rs new file mode 100644 index 0000000..076e08b --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-infer/src/lib.rs @@ -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, + /// 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, 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), + /// 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, + /// Pasos abstractos — para mostrar al usuario. + pub steps: Vec, + /// Las líneas reales de la ocurrencia más reciente — ejecutables. + pub example: Vec, + /// Cuántas veces apareció el patrón. + pub occurrences: usize, + /// Directorios donde arrancó el patrón, sin repetir. + pub directories: Vec, +} + +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 = (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 = 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 { + // firma → posiciones de inicio de las ventanas que la producen. + let mut windows: BTreeMap, Vec> = 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 = 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, Vec)> = 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 = 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)> { + 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 { + 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()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-intent/Cargo.toml b/02_ruway/shuma/sandbox/shuma-intent/Cargo.toml new file mode 100644 index 0000000..ce32375 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-intent/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/sandbox/shuma-intent/LEEME.md b/02_ruway/shuma/sandbox/shuma-intent/LEEME.md new file mode 100644 index 0000000..75ed321 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-intent/LEEME.md @@ -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) diff --git a/02_ruway/shuma/sandbox/shuma-intent/README.md b/02_ruway/shuma/sandbox/shuma-intent/README.md new file mode 100644 index 0000000..1a91a08 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-intent/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) diff --git a/02_ruway/shuma/sandbox/shuma-intent/src/graph.rs b/02_ruway/shuma/sandbox/shuma-intent/src/graph.rs new file mode 100644 index 0000000..c858dfb --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-intent/src/graph.rs @@ -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, + 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, + 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) -> 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 { + 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 { + 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); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-intent/src/lib.rs b/02_ruway/shuma/sandbox/shuma-intent/src/lib.rs new file mode 100644 index 0000000..d520c89 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-intent/src/lib.rs @@ -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}; diff --git a/02_ruway/shuma/sandbox/shuma-intent/src/macros.rs b/02_ruway/shuma/sandbox/shuma-intent/src/macros.rs new file mode 100644 index 0000000..a1f0eca --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-intent/src/macros.rs @@ -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, + /// Líneas de prompt que ejecuta, en orden. + pub intentions: Vec, +} + +impl Macro { + pub fn new(name: impl Into) -> Self { + Self { name: name.into(), key: None, intentions: Vec::new() } + } + + /// Builder: asigna una tecla. + pub fn bind(mut self, key: impl Into) -> Self { + self.key = Some(key.into()); + self + } + + /// Builder: agrega una intención. + pub fn step(mut self, intention: impl Into) -> 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, +} + +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"]); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-intent/src/parse.rs b/02_ruway/shuma/sandbox/shuma-intent/src/parse.rs new file mode 100644 index 0000000..2fbb95a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-intent/src/parse.rs @@ -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 { + 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, +} + +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 { + 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()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/Cargo.toml b/02_ruway/shuma/sandbox/shuma-line/Cargo.toml new file mode 100644 index 0000000..0130da9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/Cargo.toml @@ -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 } diff --git a/02_ruway/shuma/sandbox/shuma-line/LEEME.md b/02_ruway/shuma/sandbox/shuma-line/LEEME.md new file mode 100644 index 0000000..c881b23 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/LEEME.md @@ -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) diff --git a/02_ruway/shuma/sandbox/shuma-line/README.md b/02_ruway/shuma/sandbox/shuma-line/README.md new file mode 100644 index 0000000..d5c6ba9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/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) diff --git a/02_ruway/shuma/sandbox/shuma-line/src/ansi.rs b/02_ruway/shuma/sandbox/shuma-line/src/ansi.rs new file mode 100644 index 0000000..b17ac56 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/ansi.rs @@ -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[(;)*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`. 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, + pub bg: Option, + 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 { + 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::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 { + // 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 = 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::>() + .join("") +} + +fn apply_sgr(style: &mut AnsiStyle, params: &str) { + let nums: Vec = if params.is_empty() { + vec![0] + } else { + params + .split(';') + .map(|p| p.parse::().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;` 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 { + 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"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/src/complete.rs b/02_ruway/shuma/sandbox/shuma-line/src/complete.rs new file mode 100644 index 0000000..9d2a3d2 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/complete.rs @@ -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; + /// Rutas de archivo que empiezan con `prefix`. + fn paths(&self, prefix: &str) -> Vec; + /// 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/.toml`). + fn flags(&self, command: &str) -> Vec { + 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, + /// 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) -> Vec { + 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 = 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, + pub paths: Vec, +} + +impl CompletionSource for StaticSource { + fn commands(&self) -> Vec { + self.commands.clone() + } + fn paths(&self, prefix: &str) -> Vec { + 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/.toml). + #[derive(Default)] + struct CustomSource { + commands: Vec, + } + impl CompletionSource for CustomSource { + fn commands(&self) -> Vec { + self.commands.clone() + } + fn paths(&self, _: &str) -> Vec { + Vec::new() + } + fn flags(&self, command: &str) -> Vec { + 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())); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/src/continuation.rs b/02_ruway/shuma/sandbox/shuma-line/src/continuation.rs new file mode 100644 index 0000000..dc40cd4 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/continuation.rs @@ -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 < bool { + let mut single_q = false; + let mut double_q = false; + let mut depth_paren: i32 = 0; + let mut heredoc_tag: Option = 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 = 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 < 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, + }, + /// SHA de git (hex 7..40 chars). El frontend sugiere `git show + /// ` 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 { + let mut out: Vec = 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 = 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) { + let mut start: Option = 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) { + 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) { + 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::() { + 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) { + 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) { + // 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 `::`. + // 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 `:`. 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 `:`. + let mut col: Option = 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) { + 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 { + 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(_)))); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/src/dialect.rs b/02_ruway/shuma/sandbox/shuma-line/src/dialect.rs new file mode 100644 index 0000000..72db4ec --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/dialect.rs @@ -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", + } + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/src/editor.rs b/02_ruway/shuma/sandbox/shuma-line/src/editor.rs new file mode 100644 index 0000000..56d6c9b --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/editor.rs @@ -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) { + 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 { + 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"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/src/ghost.rs b/02_ruway/shuma/sandbox/shuma-line/src/ghost.rs new file mode 100644 index 0000000..39487bc --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/ghost.rs @@ -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 { + 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); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/src/icon.rs b/02_ruway/shuma/sandbox/shuma-line/src/icon.rs new file mode 100644 index 0000000..a46dc6e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/icon.rs @@ -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 { + 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); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/src/lexer.rs b/02_ruway/shuma/sandbox/shuma-line/src/lexer.rs new file mode 100644 index 0000000..b4ec5fa --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/lexer.rs @@ -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 { + 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 { + 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 { + 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 = Vec::new(); + let push = |tokens: &mut Vec, 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) -> Vec { + 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 { + 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()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/src/lib.rs b/02_ruway/shuma/sandbox/shuma-line/src/lib.rs new file mode 100644 index 0000000..2a3cc43 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/lib.rs @@ -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}; diff --git a/02_ruway/shuma/sandbox/shuma-line/src/pipeline.rs b/02_ruway/shuma/sandbox/shuma-line/src/pipeline.rs new file mode 100644 index 0000000..b57a76e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/pipeline.rs @@ -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, + /// Argumentos y flags, en orden de aparición. + pub args: Vec, + /// Todos los tokens de la etapa (sin la `|` que la separa). + pub tokens: Vec, +} + +impl Stage { + fn from_tokens(tokens: Vec) -> 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, +} + +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 = 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()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-line/src/token.rs b/02_ruway/shuma/sandbox/shuma-line/src/token.rs new file mode 100644 index 0000000..ffa7143 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-line/src/token.rs @@ -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 + } +} diff --git a/02_ruway/shuma/sandbox/shuma-link/Cargo.toml b/02_ruway/shuma/sandbox/shuma-link/Cargo.toml new file mode 100644 index 0000000..4aff62d --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-link/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "shuma-link" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — capa de transporte autenticado (Noise_XK + X25519 + ChaCha20Poly1305). Base para que shuma-remote-exec hable con un daemon remoto sin SSH externo." + +[dependencies] +snow = { workspace = true } +hex = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +directories = { workspace = true } +serde = { workspace = true } +postcard = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-link/LEEME.md b/02_ruway/shuma/sandbox/shuma-link/LEEME.md new file mode 100644 index 0000000..141555a --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-link/LEEME.md @@ -0,0 +1,9 @@ +# shuma-link + +> Links clickables en output de [shuma](../../README.md). + +Detecta URLs, paths absolutos, `file:line:col` y los hace clickables. Click abre browser / editor según tipo. + +## Deps + +- [`shuma-core`](../shuma-core/README.md), `regex` diff --git a/02_ruway/shuma/sandbox/shuma-link/README.md b/02_ruway/shuma/sandbox/shuma-link/README.md new file mode 100644 index 0000000..d1d20f1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-link/README.md @@ -0,0 +1,9 @@ +# shuma-link + +> Clickable output links of [shuma](../../README.md). + +Detects URLs, absolute paths, `file:line:col` and makes them clickable. Click opens the browser / editor by type. + +## Deps + +- [`shuma-core`](../shuma-core/README.md), `regex` diff --git a/02_ruway/shuma/sandbox/shuma-link/src/channel.rs b/02_ruway/shuma/sandbox/shuma-link/src/channel.rs new file mode 100644 index 0000000..e8ad7b5 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-link/src/channel.rs @@ -0,0 +1,231 @@ +//! Canal cifrado post-handshake. +//! +//! Tras `client_handshake`/`server_handshake`, ambas partes tienen una +//! `snow::TransportState`. Este módulo expone un wrapper que envía y +//! recibe **payloads opacos** sobre un `AsyncRead + AsyncWrite`, +//! delegando en Noise el cifrado/auth de cada mensaje. +//! +//! Wire por mensaje: `[u32 BE length][N bytes ciphertext]`. El cipher +//! es ChaCha20-Poly1305 con autenticación implícita (Poly1305 tag de +//! 16 B incluido en `ciphertext`). Tope de payload por mensaje: 65 519 +//! bytes (Noise max 65 535 − 16 de tag). Llamadas con payload mayor +//! devuelven `FrameError::Oversize`. + +use std::sync::{Arc, Mutex}; + +use thiserror::Error; +use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf}; + +/// Tope del payload claro por mensaje Noise (en bytes). +pub const MAX_PAYLOAD: usize = 65535 - 16; + +/// Canal cifrado bidireccional sobre cualquier transporte +/// `AsyncRead + AsyncWrite`. Tras el handshake, todo va por aquí. +pub struct FramedChannel { + stream: S, + // `snow::TransportState` no es `Sync` ni soporta usar el mismo + // estado para encrypt y decrypt sin sincronizar el counter — lo + // metemos en un Mutex porque send/recv pueden invocarse desde + // tareas distintas. Cada operación toma el lock corto. + noise: Mutex, +} + +impl FramedChannel +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, +{ + /// Construye el canal sobre `stream`. El `noise` debe venir de un + /// handshake completado (`into_transport_mode()` ya llamado). + pub fn new(stream: S, noise: snow::TransportState) -> Self { + Self { stream, noise: Mutex::new(noise) } + } + + /// Envía `payload` cifrado y autenticado. Devuelve error si supera + /// `MAX_PAYLOAD`. + pub async fn send(&mut self, payload: &[u8]) -> Result<(), FrameError> { + if payload.len() > MAX_PAYLOAD { + return Err(FrameError::Oversize(payload.len())); + } + let mut ct = vec![0u8; payload.len() + 16]; + let n = { + let mut noise = self.noise.lock().expect("noise mutex"); + noise.write_message(payload, &mut ct).map_err(FrameError::Snow)? + }; + ct.truncate(n); + let len = (n as u32).to_be_bytes(); + self.stream.write_all(&len).await.map_err(FrameError::Io)?; + self.stream.write_all(&ct).await.map_err(FrameError::Io)?; + self.stream.flush().await.map_err(FrameError::Io)?; + Ok(()) + } + + /// Recibe el próximo mensaje. Bloquea hasta que llega o el peer + /// cierra (en cuyo caso retorna `Closed`). + pub async fn recv(&mut self) -> Result, FrameError> { + let mut len_buf = [0u8; 4]; + match self.stream.read_exact(&mut len_buf).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + return Err(FrameError::Closed) + } + Err(e) => return Err(FrameError::Io(e)), + } + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_PAYLOAD + 16 { + return Err(FrameError::Oversize(len)); + } + let mut ct = vec![0u8; len]; + self.stream.read_exact(&mut ct).await.map_err(FrameError::Io)?; + let mut pt = vec![0u8; len]; + let n = { + let mut noise = self.noise.lock().expect("noise mutex"); + noise.read_message(&ct, &mut pt).map_err(FrameError::Snow)? + }; + pt.truncate(n); + Ok(pt) + } + + /// Devuelve el stream subyacente, descartando el estado Noise. + /// Útil al cerrar para drenar el TCP/Unix subyacente. + pub fn into_inner(self) -> S { + self.stream + } + + /// Parte el canal en mitades **lectura** y **escritura** que se pueden + /// usar concurrentemente desde tareas/ramas `select!` distintas — + /// necesario para full-duplex (PTY remoto: leer teclas mientras se + /// escribe la salida del terminal). El estado Noise se comparte por + /// `Arc`: cada operación toma el lock sólo para la transformación + /// cripto en memoria (no a través de un `await`), así que un `recv` + /// bloqueado esperando bytes nunca frena al `send` del otro lado. + pub fn split(self) -> (FramedReader, FramedWriter) { + let (rd, wr) = tokio::io::split(self.stream); + let noise = Arc::new(self.noise); + ( + FramedReader { rd, noise: Arc::clone(&noise) }, + FramedWriter { wr, noise }, + ) + } + + /// Conveniencia: serializa `msg` con postcard y lo envía como un + /// frame cifrado. El daemon y `shuma-remote-exec` lo usan para + /// emitir `Request`/`Response` sobre la conexión autenticada, + /// reemplazando los `write_frame`/`read_frame` de `shuma-protocol` + /// cuando hablan por la red. + pub async fn send_postcard( + &mut self, + msg: &T, + ) -> Result<(), FrameError> { + let bytes = postcard::to_allocvec(msg).map_err(FrameError::Postcard)?; + self.send(&bytes).await + } + + /// Variante de [`FramedChannel::recv`] que deserializa con postcard. + pub async fn recv_postcard(&mut self) -> Result + where + T: for<'de> serde::Deserialize<'de>, + { + let bytes = self.recv().await?; + postcard::from_bytes(&bytes).map_err(FrameError::Postcard) + } +} + +/// Mitad de **lectura** de un [`FramedChannel`] splitteado. Sólo recibe. +/// El estado Noise lo comparte (vía `Arc`) con su +/// [`FramedWriter`] hermano. +pub struct FramedReader { + rd: ReadHalf, + noise: Arc>, +} + +/// Mitad de **escritura** de un [`FramedChannel`] splitteado. Sólo envía. +pub struct FramedWriter { + wr: WriteHalf, + noise: Arc>, +} + +impl FramedReader +where + S: tokio::io::AsyncRead + Unpin, +{ + /// Espejo de [`FramedChannel::recv`] sobre la mitad de lectura. + pub async fn recv(&mut self) -> Result, FrameError> { + let mut len_buf = [0u8; 4]; + match self.rd.read_exact(&mut len_buf).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + return Err(FrameError::Closed) + } + Err(e) => return Err(FrameError::Io(e)), + } + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_PAYLOAD + 16 { + return Err(FrameError::Oversize(len)); + } + let mut ct = vec![0u8; len]; + self.rd.read_exact(&mut ct).await.map_err(FrameError::Io)?; + let mut pt = vec![0u8; len]; + let n = { + let mut noise = self.noise.lock().expect("noise mutex"); + noise.read_message(&ct, &mut pt).map_err(FrameError::Snow)? + }; + pt.truncate(n); + Ok(pt) + } + + /// Espejo de [`FramedChannel::recv_postcard`] sobre la mitad de lectura. + pub async fn recv_postcard(&mut self) -> Result + where + T: for<'de> serde::Deserialize<'de>, + { + let bytes = self.recv().await?; + postcard::from_bytes(&bytes).map_err(FrameError::Postcard) + } +} + +impl FramedWriter +where + S: tokio::io::AsyncWrite + Unpin, +{ + /// Espejo de [`FramedChannel::send`] sobre la mitad de escritura. + pub async fn send(&mut self, payload: &[u8]) -> Result<(), FrameError> { + if payload.len() > MAX_PAYLOAD { + return Err(FrameError::Oversize(payload.len())); + } + let mut ct = vec![0u8; payload.len() + 16]; + let n = { + let mut noise = self.noise.lock().expect("noise mutex"); + noise.write_message(payload, &mut ct).map_err(FrameError::Snow)? + }; + ct.truncate(n); + let len = (n as u32).to_be_bytes(); + self.wr.write_all(&len).await.map_err(FrameError::Io)?; + self.wr.write_all(&ct).await.map_err(FrameError::Io)?; + self.wr.flush().await.map_err(FrameError::Io)?; + Ok(()) + } + + /// Espejo de [`FramedChannel::send_postcard`] sobre la mitad de escritura. + pub async fn send_postcard( + &mut self, + msg: &T, + ) -> Result<(), FrameError> { + let bytes = postcard::to_allocvec(msg).map_err(FrameError::Postcard)?; + self.send(&bytes).await + } +} + +/// Errores del canal cifrado. +#[derive(Debug, Error)] +pub enum FrameError { + #[error("payload oversize: {0} bytes (max {MAX_PAYLOAD})")] + Oversize(usize), + #[error("io: {0}")] + Io(std::io::Error), + #[error("noise: {0}")] + Snow(snow::Error), + #[error("postcard: {0}")] + Postcard(postcard::Error), + #[error("conexión cerrada")] + Closed, +} diff --git a/02_ruway/shuma/sandbox/shuma-link/src/handshake.rs b/02_ruway/shuma/sandbox/shuma-link/src/handshake.rs new file mode 100644 index 0000000..c76f2b1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-link/src/handshake.rs @@ -0,0 +1,313 @@ +//! Handshake Noise_XK (X25519 + ChaCha20-Poly1305 + BLAKE2s). +//! +//! Roles: +//! +//! - **Client** ([`client_handshake`]): conoce de antemano la pubkey +//! del server (igual que `known_hosts` de SSH). Envía 3 mensajes +//! Noise (`e`, `es; s, ss`, vacío); al final tiene una +//! `TransportState` lista para enviar y recibir. +//! - **Server** ([`server_handshake`]): conoce su propia keypair y +//! recibe la pubkey del cliente DURANTE el handshake. Tras +//! completarlo, la pubkey del cliente se extrae con +//! `get_remote_static()` y la decisión de aceptar/rechazar pasa al +//! caller (típicamente: validar contra `KnownPeers`). +//! +//! Diseño tipo SSH: client confía en server por pre-shared pubkey +//! (no TOFU automático; en una shell remota el TOFU se vuelve un +//! TOCTOU); server confía en client por allowlist explícita +//! (`~/.config/shuma/known_peers.txt`). + +use thiserror::Error; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::channel::FramedChannel; +use crate::identity::{noise_pattern, Keypair, KeypairError, PublicKey, KEY_LEN}; + +/// Tope de tamaño de un mensaje de handshake — viene de Noise (65 535 +/// bytes), pero los handshakes XK son <200 B. Reservamos por si en el +/// futuro añadimos prólogo con metadatos (versión protocolo, etc.). +const HANDSHAKE_FRAME_MAX: usize = 4096; + +/// Cliente Noise_XK: conecta y autentica. +/// +/// `expected_server` es la pubkey que el cliente espera. Si el server +/// se identifica con otra, el handshake falla con `WrongServerKey`. +/// Tras completar, devuelve un canal cifrado listo para mensajes de +/// aplicación. +pub async fn client_handshake( + mut stream: S, + our_keypair: &Keypair, + expected_server: PublicKey, +) -> Result, HandshakeError> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, +{ + let pattern = noise_pattern().map_err(HandshakeError::Keypair)?; + let mut hs = snow::Builder::new(pattern) + .local_private_key(our_keypair.private_bytes()) + .remote_public_key(expected_server.as_bytes()) + .build_initiator() + .map_err(HandshakeError::Snow)?; + + let mut buf = vec![0u8; HANDSHAKE_FRAME_MAX]; + // Mensaje 1 (cliente → server): -> e + let n = hs.write_message(&[], &mut buf).map_err(HandshakeError::Snow)?; + write_msg(&mut stream, &buf[..n]).await?; + // Mensaje 2 (server → cliente): <- e, ee, s, es + let resp = read_msg(&mut stream).await?; + let _ = hs.read_message(&resp, &mut buf).map_err(HandshakeError::Snow)?; + // Mensaje 3 (cliente → server): -> s, se + let n = hs.write_message(&[], &mut buf).map_err(HandshakeError::Snow)?; + write_msg(&mut stream, &buf[..n]).await?; + + // En XK el cliente conoce la pubkey del server desde el principio, + // así que `get_remote_static` después del handshake sólo confirma + // lo que ya validó snow. + let remote = hs + .get_remote_static() + .ok_or(HandshakeError::NoRemoteStatic)?; + if remote != expected_server.as_bytes() { + return Err(HandshakeError::WrongServerKey); + } + let transport = hs.into_transport_mode().map_err(HandshakeError::Snow)?; + Ok(FramedChannel::new(stream, transport)) +} + +/// Servidor Noise_XK: acepta una conexión y autentica el cliente. +/// +/// Devuelve `(channel, peer_pubkey)` — el caller decide si la +/// `peer_pubkey` está autorizada (típicamente: `KnownPeers::contains`). +pub async fn server_handshake( + mut stream: S, + our_keypair: &Keypair, +) -> Result<(FramedChannel, PublicKey), HandshakeError> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, +{ + let pattern = noise_pattern().map_err(HandshakeError::Keypair)?; + let mut hs = snow::Builder::new(pattern) + .local_private_key(our_keypair.private_bytes()) + .build_responder() + .map_err(HandshakeError::Snow)?; + + let mut buf = vec![0u8; HANDSHAKE_FRAME_MAX]; + // Mensaje 1 (cliente → server) + let m1 = read_msg(&mut stream).await?; + let _ = hs.read_message(&m1, &mut buf).map_err(HandshakeError::Snow)?; + // Mensaje 2 (server → cliente) + let n = hs.write_message(&[], &mut buf).map_err(HandshakeError::Snow)?; + write_msg(&mut stream, &buf[..n]).await?; + // Mensaje 3 (cliente → server) + let m3 = read_msg(&mut stream).await?; + let _ = hs.read_message(&m3, &mut buf).map_err(HandshakeError::Snow)?; + + let remote_bytes = hs + .get_remote_static() + .ok_or(HandshakeError::NoRemoteStatic)?; + let arr: [u8; KEY_LEN] = remote_bytes + .try_into() + .map_err(|_| HandshakeError::BadPubkeyLength)?; + let peer = PublicKey(arr); + let transport = hs.into_transport_mode().map_err(HandshakeError::Snow)?; + Ok((FramedChannel::new(stream, transport), peer)) +} + +/// Frame de handshake con length-prefix u32 BE (igual layout que el +/// canal post-handshake, simétrico — facilita lectura del wire en +/// debug y tcpdump). +async fn write_msg(stream: &mut S, msg: &[u8]) -> Result<(), HandshakeError> +where + S: tokio::io::AsyncWrite + Unpin, +{ + let len = (msg.len() as u32).to_be_bytes(); + stream.write_all(&len).await.map_err(HandshakeError::Io)?; + stream.write_all(msg).await.map_err(HandshakeError::Io)?; + stream.flush().await.map_err(HandshakeError::Io)?; + Ok(()) +} + +async fn read_msg(stream: &mut S) -> Result, HandshakeError> +where + S: tokio::io::AsyncRead + Unpin, +{ + let mut len_buf = [0u8; 4]; + stream + .read_exact(&mut len_buf) + .await + .map_err(|e| { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + HandshakeError::Closed + } else { + HandshakeError::Io(e) + } + })?; + let len = u32::from_be_bytes(len_buf) as usize; + if len > HANDSHAKE_FRAME_MAX { + return Err(HandshakeError::Oversize(len)); + } + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf).await.map_err(HandshakeError::Io)?; + Ok(buf) +} + +/// Errores del handshake. +#[derive(Debug, Error)] +pub enum HandshakeError { + #[error("identidad: {0}")] + Keypair(KeypairError), + #[error("snow: {0}")] + Snow(snow::Error), + #[error("io: {0}")] + Io(std::io::Error), + #[error("conexión cerrada antes de completar el handshake")] + Closed, + #[error("frame de handshake oversize: {0} bytes")] + Oversize(usize), + #[error("server presentó una pubkey distinta a la esperada")] + WrongServerKey, + #[error("snow no expuso la pubkey remota")] + NoRemoteStatic, + #[error("la pubkey remota no tiene longitud 32")] + BadPubkeyLength, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::Keypair; + + /// Handshake completo sobre un par de UnixStream — verifica que + /// ambas partes pueden enviarse mensajes cifrados y descifrarlos + /// en ambos sentidos. + #[tokio::test] + async fn xk_handshake_round_trip() { + let server_kp = Keypair::generate().unwrap(); + let client_kp = Keypair::generate().unwrap(); + let server_pub = server_kp.public(); + + let (server_io, client_io) = tokio::net::UnixStream::pair().unwrap(); + let server_kp2 = server_kp.clone(); + let server_task = tokio::spawn(async move { + let (mut ch, peer) = server_handshake(server_io, &server_kp2).await.unwrap(); + // El primer mensaje cifrado del cliente debe llegar. + let m = ch.recv().await.unwrap(); + assert_eq!(m, b"hola server"); + // Respondemos cifrado. + ch.send(b"hola cliente").await.unwrap(); + peer + }); + let mut ch = client_handshake(client_io, &client_kp, server_pub).await.unwrap(); + ch.send(b"hola server").await.unwrap(); + let r = ch.recv().await.unwrap(); + assert_eq!(r, b"hola cliente"); + + let peer_seen_by_server = server_task.await.unwrap(); + assert_eq!( + peer_seen_by_server, + client_kp.public(), + "server vio la pubkey real del cliente" + ); + } + + /// Si el cliente espera una pubkey distinta a la que el server + /// presenta, el handshake debe fallar — protección MITM. + #[tokio::test] + async fn wrong_server_key_aborts_handshake() { + let real_server = Keypair::generate().unwrap(); + let attacker = Keypair::generate().unwrap(); + let client = Keypair::generate().unwrap(); + + let (server_io, client_io) = tokio::net::UnixStream::pair().unwrap(); + let attacker_clone = attacker.clone(); + let server_task = tokio::spawn(async move { + // El "atacante" es el que responde con su propia keypair. + server_handshake(server_io, &attacker_clone).await + }); + // El cliente espera la pubkey del server real. + let res = client_handshake(client_io, &client, real_server.public()).await; + assert!( + res.is_err(), + "el handshake con pubkey equivocada debe fallar" + ); + // No esperamos del server_task — su error/éxito no es lo que + // estamos validando. + let _ = server_task.await; + } + + /// Múltiples mensajes en ambos sentidos — counter de Noise + framing + /// se mantienen sincronizados a lo largo de la sesión. + #[tokio::test] + async fn multiple_messages_keep_counters_in_sync() { + let server_kp = Keypair::generate().unwrap(); + let client_kp = Keypair::generate().unwrap(); + let server_pub = server_kp.public(); + + let (server_io, client_io) = tokio::net::UnixStream::pair().unwrap(); + let server_kp2 = server_kp.clone(); + let server_task = tokio::spawn(async move { + let (mut ch, _) = server_handshake(server_io, &server_kp2).await.unwrap(); + for i in 0..20 { + let msg = ch.recv().await.unwrap(); + assert_eq!(msg, format!("ping {i}").as_bytes()); + ch.send(format!("pong {i}").as_bytes()).await.unwrap(); + } + }); + let mut ch = client_handshake(client_io, &client_kp, server_pub).await.unwrap(); + for i in 0..20 { + ch.send(format!("ping {i}").as_bytes()).await.unwrap(); + let pong = ch.recv().await.unwrap(); + assert_eq!(pong, format!("pong {i}").as_bytes()); + } + server_task.await.unwrap(); + } + + /// Round-trip de un valor serializado con postcard sobre el canal + /// cifrado — demuestra que daemon y remote-exec van a poder enviar + /// sus Request/Response sin tocar el bucle de framing. + #[tokio::test] + async fn postcard_round_trips_over_encrypted_channel() { + use serde::{Deserialize, Serialize}; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + struct Msg { + label: String, + n: u64, + } + + let server_kp = Keypair::generate().unwrap(); + let client_kp = Keypair::generate().unwrap(); + let server_pub = server_kp.public(); + let (server_io, client_io) = tokio::net::UnixStream::pair().unwrap(); + let server_kp2 = server_kp.clone(); + let server_task = tokio::spawn(async move { + let (mut ch, _) = server_handshake(server_io, &server_kp2).await.unwrap(); + let m: Msg = ch.recv_postcard().await.unwrap(); + assert_eq!(m, Msg { label: "hola".into(), n: 42 }); + ch.send_postcard(&Msg { label: "ok".into(), n: 43 }).await.unwrap(); + }); + let mut ch = client_handshake(client_io, &client_kp, server_pub).await.unwrap(); + ch.send_postcard(&Msg { label: "hola".into(), n: 42 }).await.unwrap(); + let r: Msg = ch.recv_postcard().await.unwrap(); + assert_eq!(r, Msg { label: "ok".into(), n: 43 }); + server_task.await.unwrap(); + } + + /// Si el cierre del peer corta la conexión mitad de stream, `recv` + /// devuelve `Closed`, no se cuelga. + #[tokio::test] + async fn peer_close_translates_to_closed_error() { + let server_kp = Keypair::generate().unwrap(); + let client_kp = Keypair::generate().unwrap(); + let server_pub = server_kp.public(); + let (server_io, client_io) = tokio::net::UnixStream::pair().unwrap(); + let server_kp2 = server_kp.clone(); + let server_task = tokio::spawn(async move { + let (ch, _) = server_handshake(server_io, &server_kp2).await.unwrap(); + // Server cierra el canal inmediatamente. + drop(ch); + }); + let mut ch = client_handshake(client_io, &client_kp, server_pub).await.unwrap(); + let r = ch.recv().await; + assert!(matches!(r, Err(crate::channel::FrameError::Closed))); + server_task.await.unwrap(); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-link/src/identity.rs b/02_ruway/shuma/sandbox/shuma-link/src/identity.rs new file mode 100644 index 0000000..51f0f42 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-link/src/identity.rs @@ -0,0 +1,264 @@ +//! Identidad del nodo — par X25519 persistente. +//! +//! El par se guarda en `~/.config/shuma/keys/identity.x25519` con +//! permisos `0600`. La pubkey se serializa como hex (64 chars) para +//! que se pueda intercambiar por copy/paste — formato a propósito +//! incompatible con `~/.ssh/authorized_keys` (no es SSH, no usamos +//! ese formato). + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +/// Longitud de una clave X25519 (privada o pública). +pub const KEY_LEN: usize = 32; + +/// Una clave pública X25519 — 32 bytes. Identifica un nodo +/// indeleblemente para `shuma-link`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PublicKey(pub [u8; KEY_LEN]); + +impl PublicKey { + pub fn from_bytes(b: [u8; KEY_LEN]) -> Self { + Self(b) + } + + pub fn as_bytes(&self) -> &[u8; KEY_LEN] { + &self.0 + } + + /// Formato canónico hex en minúsculas, sin separadores. Coincide + /// con `hex::encode` y es lo que aparece en `known_peers.txt`. + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } + + /// Parsea hex (con o sin prefijo `0x`, case-insensitive). Devuelve + /// error si la longitud no es exacta. + pub fn from_hex(s: &str) -> Result { + let trimmed = s.trim(); + let body = trimmed.strip_prefix("0x").unwrap_or(trimmed); + let bytes = hex::decode(body).map_err(|_| KeypairError::InvalidHex)?; + let arr: [u8; KEY_LEN] = bytes.try_into().map_err(|_| KeypairError::WrongLength)?; + Ok(Self(arr)) + } +} + +/// Par X25519 (privada + pública). La privada **no** debe filtrarse — +/// `Debug` la enmascara y `Drop` no la cero-iza (snow lo hace al +/// crear las sesiones; mientras esté en memoria del proceso es OK). +#[derive(Clone)] +pub struct Keypair { + private: [u8; KEY_LEN], + public: PublicKey, +} + +impl std::fmt::Debug for Keypair { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Keypair") + .field("public", &self.public.to_hex()) + .field("private", &"") + .finish() + } +} + +impl Keypair { + /// Genera un par nuevo con el RNG del sistema (vía snow, que usa + /// `rand::rngs::OsRng` por debajo). Es la operación más cara de + /// este crate (≈ 1 ms). + pub fn generate() -> Result { + let builder = snow::Builder::new(noise_pattern()?); + let kp = builder.generate_keypair().map_err(KeypairError::Snow)?; + let private: [u8; KEY_LEN] = kp + .private + .as_slice() + .try_into() + .map_err(|_| KeypairError::WrongLength)?; + let public_bytes: [u8; KEY_LEN] = kp + .public + .as_slice() + .try_into() + .map_err(|_| KeypairError::WrongLength)?; + Ok(Self { private, public: PublicKey(public_bytes) }) + } + + /// La pubkey — segura de exportar/loggear. + pub fn public(&self) -> PublicKey { + self.public + } + + /// Bytes de la pubkey — `&[u8; 32]`. + pub fn public_bytes(&self) -> &[u8; KEY_LEN] { + &self.public.0 + } + + /// Acceso a los bytes privados — `snow::Builder` los consume al + /// arrancar el handshake. No exportar a logs. + pub fn private_bytes(&self) -> &[u8; KEY_LEN] { + &self.private + } + + /// Carga el par desde `path`. Espera 64 bytes: `[priv][pub]`. + pub fn load(path: impl AsRef) -> Result { + let bytes = fs::read(path.as_ref()).map_err(|e| KeypairError::Io(path.as_ref().to_path_buf(), e))?; + if bytes.len() != KEY_LEN * 2 { + return Err(KeypairError::WrongLength); + } + let private: [u8; KEY_LEN] = bytes[..KEY_LEN].try_into().unwrap(); + let public: [u8; KEY_LEN] = bytes[KEY_LEN..].try_into().unwrap(); + Ok(Self { private, public: PublicKey(public) }) + } + + /// Guarda el par en `path` (`0600` en Unix). Crea el padre si falta. + pub fn save(&self, path: impl AsRef) -> Result<(), KeypairError> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| KeypairError::Io(parent.to_path_buf(), e))?; + } + let mut out = Vec::with_capacity(KEY_LEN * 2); + out.extend_from_slice(&self.private); + out.extend_from_slice(&self.public.0); + // Crear con `0600` desde el principio para no exponer la clave + // privada en una micro-ventana de tiempo. En no-Unix, sólo + // truncamos. + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut f = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path) + .map_err(|e| KeypairError::Io(path.to_path_buf(), e))?; + f.write_all(&out) + .map_err(|e| KeypairError::Io(path.to_path_buf(), e))?; + f.flush() + .map_err(|e| KeypairError::Io(path.to_path_buf(), e))?; + } + #[cfg(not(unix))] + { + fs::write(path, &out).map_err(|e| KeypairError::Io(path.to_path_buf(), e))?; + } + Ok(()) + } + + /// Conveniencia: ruta canónica `~/.config/shuma/keys/identity.x25519`. + /// `None` si el SO no expone un directorio de configuración. + pub fn default_path() -> Option { + directories::ProjectDirs::from("", "", "shuma") + .map(|d| d.config_dir().join("keys").join("identity.x25519")) + } + + /// Carga `path` si existe, o genera uno nuevo, lo guarda y lo + /// devuelve. El patrón típico de un daemon al arrancar. + pub fn load_or_generate(path: impl AsRef) -> Result { + if path.as_ref().exists() { + Self::load(path) + } else { + let kp = Self::generate()?; + kp.save(&path)?; + Ok(kp) + } + } +} + +/// Patrón Noise que usa todo el crate: XK con X25519 + ChaCha20Poly1305 +/// + BLAKE2s. El nombre lo entiende `snow::Builder::new`. +pub(crate) fn noise_pattern() -> Result { + "Noise_XK_25519_ChaChaPoly_BLAKE2s" + .parse() + .map_err(|_| KeypairError::BadPattern) +} + +/// Errores del módulo identity. +#[derive(Debug, Error)] +pub enum KeypairError { + #[error("IO en {}: {}", .0.display(), .1)] + Io(PathBuf, std::io::Error), + #[error("snow: {0}")] + Snow(snow::Error), + #[error("longitud de clave incorrecta — esperaba {KEY_LEN} bytes")] + WrongLength, + #[error("hex inválido en la clave")] + InvalidHex, + #[error("patrón Noise mal formado")] + BadPattern, +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn generate_yields_different_keypairs() { + let a = Keypair::generate().unwrap(); + let b = Keypair::generate().unwrap(); + assert_ne!(a.public(), b.public()); + } + + #[test] + fn save_and_load_round_trip() { + let d = tempdir().unwrap(); + let path = d.path().join("id.x25519"); + let kp = Keypair::generate().unwrap(); + kp.save(&path).unwrap(); + let back = Keypair::load(&path).unwrap(); + assert_eq!(kp.public(), back.public()); + assert_eq!(kp.private_bytes(), back.private_bytes()); + } + + #[test] + fn saved_file_is_owner_only_on_unix() { + let d = tempdir().unwrap(); + let path = d.path().join("id.x25519"); + Keypair::generate().unwrap().save(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(&path).unwrap(); + let mode = meta.permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "keypair file no es 0600: {:o}", mode); + } + } + + #[test] + fn load_or_generate_is_idempotent() { + let d = tempdir().unwrap(); + let path = d.path().join("id.x25519"); + let a = Keypair::load_or_generate(&path).unwrap(); + let b = Keypair::load_or_generate(&path).unwrap(); + assert_eq!(a.public(), b.public()); + } + + #[test] + fn public_key_hex_round_trip() { + let kp = Keypair::generate().unwrap(); + let h = kp.public().to_hex(); + assert_eq!(h.len(), 64); + let back = PublicKey::from_hex(&h).unwrap(); + assert_eq!(back, kp.public()); + // Con prefijo 0x también funciona. + let back2 = PublicKey::from_hex(&format!("0x{h}")).unwrap(); + assert_eq!(back2, kp.public()); + } + + #[test] + fn invalid_hex_returns_error() { + assert!(PublicKey::from_hex("xy").is_err()); + assert!(PublicKey::from_hex("0xZZ").is_err()); + // Largo incorrecto. + assert!(PublicKey::from_hex("0001").is_err()); + } + + #[test] + fn malformed_file_fails_load() { + let d = tempdir().unwrap(); + let p = d.path().join("bad"); + std::fs::write(&p, b"too-short").unwrap(); + assert!(matches!(Keypair::load(&p), Err(KeypairError::WrongLength))); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-link/src/lib.rs b/02_ruway/shuma/sandbox/shuma-link/src/lib.rs new file mode 100644 index 0000000..af35ea4 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-link/src/lib.rs @@ -0,0 +1,51 @@ +//! `shuma-link` — capa de transporte autenticado del daemon shuma. +//! +//! Reemplaza el "Unix socket + SO_PEERCRED" (válido sólo localmente) por +//! un canal **autenticado y cifrado** sobre cualquier transporte +//! `AsyncRead + AsyncWrite` (TCP, Unix socket sobre el escritorio +//! compartido, lo que toque). Es la palanca que convierte +//! `shuma-remote-exec` en un cliente de un daemon **remoto**, sin tener +//! que apoyarse en SSH externo ni en mosh. +//! +//! ## Patrón Noise_XK +//! +//! - **X**: cliente envía su pubkey estática durante el handshake +//! (anonymity property: la pubkey va cifrada). +//! - **K**: cliente conoce la pubkey del servidor *de antemano* (igual +//! que el `known_hosts` de SSH). Cualquier man-in-the-middle falla +//! el handshake. +//! +//! Resultado: mutual auth, forward secrecy, replay-protection y +//! 0-RTT en el primer mensaje del cliente tras el handshake. +//! +//! ## Componentes +//! +//! - [`identity::Keypair`] — par X25519 persistente del nodo +//! (`~/.config/shuma/keys/identity.x25519`). Se genera al primer +//! arranque y se reusa después. +//! - [`peers::KnownPeers`] — set de pubkeys confiables (allowlist +//! estilo `~/.ssh/authorized_keys`, en +//! `~/.config/shuma/known_peers.txt`). +//! - [`handshake::client_handshake`] / [`handshake::server_handshake`] +//! — establecen el canal Noise sobre un `AsyncRead+AsyncWrite`. +//! - [`channel::FramedChannel`] — wrapper post-handshake que envía y +//! recibe payloads opacos (bytes), con framing + cifrado/MAC +//! ChaCha20-Poly1305 + counter de Noise. +//! +//! Las helpers de framing del protocolo (`shuma_protocol::read_frame`, +//! `write_frame`) NO se reutilizan aquí: el canal cifrado tiene su +//! propio length-prefix interior por mensaje Noise (max 65 535 B). +//! Para payloads más grandes, `FramedChannel` los partiría — hoy el +//! protocolo de shuma cabe muy holgado en un solo Noise frame. + +#![forbid(unsafe_code)] + +pub mod channel; +pub mod handshake; +pub mod identity; +pub mod peers; + +pub use channel::{FramedChannel, FramedReader, FramedWriter}; +pub use handshake::{client_handshake, server_handshake, HandshakeError}; +pub use identity::{Keypair, KeypairError, PublicKey}; +pub use peers::KnownPeers; diff --git a/02_ruway/shuma/sandbox/shuma-link/src/peers.rs b/02_ruway/shuma/sandbox/shuma-link/src/peers.rs new file mode 100644 index 0000000..5349c1c --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-link/src/peers.rs @@ -0,0 +1,202 @@ +//! `KnownPeers` — el "authorized_keys" del daemon shuma. +//! +//! Formato: un archivo de texto, una línea por pubkey hex (con `#` para +//! comentarios y líneas vacías ignoradas). Mismo espíritu que +//! `~/.ssh/authorized_keys` pero a propósito incompatible: no es SSH y +//! no queremos que un copy/paste accidental cruce dominios. + +use std::collections::HashSet; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::identity::{KeypairError, PublicKey}; + +/// Allowlist de pubkeys que el servidor acepta. +#[derive(Debug, Clone, Default)] +pub struct KnownPeers { + keys: HashSet, +} + +impl KnownPeers { + /// Vacío — útil para tests y para el primer arranque. + pub fn new() -> Self { + Self::default() + } + + /// `true` si `key` está en la allowlist. + pub fn contains(&self, key: &PublicKey) -> bool { + self.keys.contains(key) + } + + /// Añade una clave a la allowlist. `true` si era nueva. + pub fn add(&mut self, key: PublicKey) -> bool { + self.keys.insert(key) + } + + /// Quita una clave. `true` si existía. + pub fn remove(&mut self, key: &PublicKey) -> bool { + self.keys.remove(key) + } + + /// Cantidad de claves confiables. + pub fn len(&self) -> usize { + self.keys.len() + } + + /// `true` si no hay ninguna clave confiable. + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } + + /// Itera las claves en orden no especificado. + pub fn iter(&self) -> impl Iterator { + self.keys.iter() + } + + /// Path canónico: `~/.config/shuma/known_peers.txt`. `None` si el + /// SO no expone un directorio de configuración. + pub fn default_path() -> Option { + directories::ProjectDirs::from("", "", "shuma") + .map(|d| d.config_dir().join("known_peers.txt")) + } + + /// Carga el archivo si existe, o devuelve un set vacío. Líneas que + /// empiezan con `#`, líneas en blanco y líneas con hex mal formado + /// se ignoran silenciosamente — *no* abortamos por una entrada + /// vieja o tipeada a mano. + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + if !path.exists() { + return Ok(Self::default()); + } + let text = fs::read_to_string(path) + .map_err(|e| KeypairError::Io(path.to_path_buf(), e))?; + let mut set = HashSet::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + // Permitimos ` [comentario opcional]`. + let token = trimmed.split_whitespace().next().unwrap_or(""); + if let Ok(k) = PublicKey::from_hex(token) { + set.insert(k); + } + } + Ok(Self { keys: set }) + } + + /// Guarda el set en `path` (`0600` en Unix), una clave por línea + /// con encabezado humano. Crea el padre si falta. Reescritura + /// atómica (escribe `.tmp` y `rename`). + pub fn save(&self, path: impl AsRef) -> Result<(), KeypairError> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| KeypairError::Io(parent.to_path_buf(), e))?; + } + let tmp = path.with_extension(format!( + "{}.tmp", + path.extension().and_then(|s| s.to_str()).unwrap_or("txt") + )); + let mut keys: Vec<&PublicKey> = self.keys.iter().collect(); + keys.sort_by(|a, b| a.0.cmp(&b.0)); + let mut buf = String::new(); + buf.push_str("# shuma-link known peers — una pubkey X25519 hex por línea.\n"); + buf.push_str("# Líneas en blanco y `#` se ignoran al cargar.\n"); + for k in keys { + buf.push_str(&k.to_hex()); + buf.push('\n'); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut f = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&tmp) + .map_err(|e| KeypairError::Io(tmp.clone(), e))?; + f.write_all(buf.as_bytes()) + .map_err(|e| KeypairError::Io(tmp.clone(), e))?; + f.flush().map_err(|e| KeypairError::Io(tmp.clone(), e))?; + } + #[cfg(not(unix))] + { + fs::write(&tmp, buf.as_bytes()) + .map_err(|e| KeypairError::Io(tmp.clone(), e))?; + } + fs::rename(&tmp, path).map_err(|e| KeypairError::Io(path.to_path_buf(), e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::Keypair; + use tempfile::tempdir; + + #[test] + fn add_contains_remove() { + let mut p = KnownPeers::new(); + let k = Keypair::generate().unwrap().public(); + assert!(!p.contains(&k)); + assert!(p.add(k)); + assert!(p.contains(&k)); + assert!(!p.add(k)); // segundo add: no es nuevo + assert_eq!(p.len(), 1); + assert!(p.remove(&k)); + assert!(!p.contains(&k)); + } + + #[test] + fn round_trip_through_disk() { + let d = tempdir().unwrap(); + let path = d.path().join("known.txt"); + let mut p = KnownPeers::new(); + let a = Keypair::generate().unwrap().public(); + let b = Keypair::generate().unwrap().public(); + p.add(a); + p.add(b); + p.save(&path).unwrap(); + let back = KnownPeers::load(&path).unwrap(); + assert!(back.contains(&a)); + assert!(back.contains(&b)); + assert_eq!(back.len(), 2); + } + + #[test] + fn missing_file_loads_empty() { + let d = tempdir().unwrap(); + let back = KnownPeers::load(d.path().join("nope.txt")).unwrap(); + assert!(back.is_empty()); + } + + #[test] + fn comments_and_blanks_are_skipped() { + let d = tempdir().unwrap(); + let path = d.path().join("known.txt"); + let k = Keypair::generate().unwrap().public(); + let body = format!( + "# comentario\n\n # otro\n{} # nota al lado\n", + k.to_hex() + ); + std::fs::write(&path, body).unwrap(); + let p = KnownPeers::load(&path).unwrap(); + assert_eq!(p.len(), 1); + assert!(p.contains(&k)); + } + + #[test] + fn malformed_lines_are_silently_dropped() { + let d = tempdir().unwrap(); + let path = d.path().join("known.txt"); + let k = Keypair::generate().unwrap().public(); + let body = format!("not-hex-at-all\n{}\nfaa\n", k.to_hex()); + std::fs::write(&path, body).unwrap(); + let p = KnownPeers::load(&path).unwrap(); + assert_eq!(p.len(), 1, "sólo la línea bien formada debe contar"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-canvas/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-canvas/Cargo.toml new file mode 100644 index 0000000..3cba4f7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-canvas/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shuma-module-canvas" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-module-canvas — lienzo de contexto: renderiza el SessionGraph de shuma-intent como grafo visual usando shuma-shell-render::layout y paint_with de Llimphi." + +[dependencies] +shuma-module = { path = "../shuma-module" } +shuma-intent = { path = "../shuma-intent" } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-canvas/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-canvas/src/lib.rs new file mode 100644 index 0000000..0bd9af7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-canvas/src/lib.rs @@ -0,0 +1,768 @@ +//! `shuma-module-canvas` — el **Lienzo de Contexto** del shell. +//! +//! Tab/panel que dibuja el `SessionGraph` de `shuma-intent` como un +//! grafo visual: cada comando `%cN` es una caja, las dependencias +//! `%pN` son flechas hacia el comando que las produjo. El usuario ve +//! el flujo entero de la sesión y puede saltar atrás (referencia +//! `%c3`) o "tirar de un hilo" para reusarlo. +//! +//! El layout es columnar por profundidad (longest-path): la columna +//! `0` son los comandos sin dependencias, la `N` los que dependen de +//! columnas `, +} + +impl Clone for State { + fn clone(&self) -> Self { + Self { + graph: self.graph.clone(), + scroll_y: self.scroll_y, + focused: self.focused, + } + } +} + +impl State { + pub fn new() -> Self { + Self { + graph: SessionGraph::new(), + scroll_y: 0.0, + focused: None, + } + } + + /// Grafo de demo con 4 comandos y 2 dependencias — útil para + /// probar el render sin tener un shell conectado. Las inyecciones + /// (`%pN`) son etapas separadas por `|`, como pide `shuma-intent`. + pub fn demo() -> Self { + let mut g = SessionGraph::new(); + let c1 = g.record("cat data.json"); + let _ = g.complete(c1, true, 2_400_000); // produce %p1 + let c2 = g.record("%p1 | jq '.users[]'"); + let _ = g.complete(c2, true, 800_000); // produce %p2 + let c3 = g.record("%p2 | grep -c sergio"); + let _ = g.complete(c3, false, 0); + let _c4 = g.record("%p2 | sort | head"); // running + Self { + graph: g, + scroll_y: 0.0, + focused: None, + } + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone)] +pub enum Msg { + /// Registrar una intención nueva en el grafo. Devuelve el `%cN` + /// asignado al caller via el siguiente render (no hay return-msg). + Record(String), + /// Marcar un comando como terminado. + Complete { id: u32, ok: bool, bytes: u64 }, + /// Colapsar todos los nodos exitosos (quietud visual). + CollapseSucceeded, + /// Limpiar el grafo entero. + Reset, + /// Reemplazar el grafo completo con el snapshot que viene del + /// shell. El chasis lo dispara en cada `ShellTick` para mantener + /// el lienzo en sync con el `SessionGraph` del módulo shell. + /// Si el snapshot coincide con el grafo actual, el state queda igual. + SyncGraph(SessionGraph), + /// Click sobre una caja del lienzo. Si `Some(id)` enfoca el nodo + /// (toggle: si ya estaba enfocado, lo desfoca); `None` cuando se + /// clickeó fuera de cualquier caja → desfoca. + NodeClicked(Option), + /// Pedido de insertar una referencia (`%cN`/`%pN`) en el input del + /// shell. El chasis intercepta esta variante y la routea al primer + /// `shuma-module-shell` activo como `Msg::InsertAtCursor(text)`; + /// el canvas mismo no toca el shell. Si llega al `update` del + /// canvas sin que el chasis la haya consumido, es no-op. + InsertRef(String), +} + +pub fn dispatch(action_id: &str) -> Option { + match action_id { + "canvas.collapse" => Some(Msg::CollapseSucceeded), + "canvas.reset" => Some(Msg::Reset), + _ => None, + } +} + +pub fn update(state: State, msg: Msg) -> State { + let mut s = state; + match msg { + Msg::Record(line) => { + s.graph.record(line); + } + Msg::Complete { id, ok, bytes } => { + s.graph.complete(id, ok, bytes); + } + Msg::CollapseSucceeded => { + s.graph.collapse_succeeded(); + } + Msg::Reset => { + s.graph = SessionGraph::new(); + } + Msg::SyncGraph(graph) => { + s.graph = graph; + // Si el nodo enfocado ya no existe en el snapshot nuevo, + // limpiamos el foco para que el detalle no muestre stale. + if let Some(id) = s.focused { + if !s.graph.commands().iter().any(|c| c.id == id) { + s.focused = None; + } + } + } + Msg::NodeClicked(target) => match target { + Some(id) if s.focused == Some(id) => { + // Toggle: segundo click sobre el mismo nodo lo desenfoca. + s.focused = None; + } + Some(id) => { + s.focused = Some(id); + } + None => { + s.focused = None; + } + }, + Msg::InsertRef(_) => { + // No-op acá — el chasis intercepta esta variante antes de + // que entre al update del canvas. Si llega es porque el + // canvas está corriendo standalone (sin chasis); no podemos + // hacer nada útil sin acceso al shell. + } + } + s +} + +pub fn contributions(_state: &State) -> ModuleContributions { + ModuleContributions { + monitors: Vec::new(), + shortcuts: vec![ + ShortcutSpec::module_action("Collapse", "canvas.collapse") + .with_hint("Retraer nodos exitosos"), + ShortcutSpec::module_action("Reset", "canvas.reset") + .with_hint("Vaciar el grafo"), + ], + } +} + +/// Caja precomputada para pintar. +#[derive(Clone)] +struct LaidBox { + id: u32, + label: String, + status: NodeStatus, + collapsed: bool, + x: f32, + y: f32, + w: f32, + h: f32, +} + +#[derive(Clone)] +struct LaidEdge { + from: u32, + to: u32, +} + +/// Layout columnar por profundidad. Equivalente a +/// `shuma_shell_render::layout` pero in-tree para no arrastrar +/// `pineal-render` al chasis. +fn layout(graph: &SessionGraph) -> (Vec, Vec) { + const NODE_W: f32 = 160.0; + const NODE_H: f32 = 56.0; + const COLLAPSED_H: f32 = 22.0; + const COL_GAP: f32 = 64.0; + const ROW_GAP: f32 = 20.0; + const ORIGIN_X: f32 = 16.0; + const ORIGIN_Y: f32 = 16.0; + + let cmds = graph.commands(); + let mut edges: Vec = Vec::new(); + let mut depth: Vec<(u32, usize)> = Vec::with_capacity(cmds.len()); + for c in cmds { + let refs = Intention::parse(&c.intention).refs(); + let mut d = 0usize; + for r in refs { + if let Some(producer) = graph.resolve(r) { + edges.push(LaidEdge { + from: producer.id, + to: c.id, + }); + let pd = depth + .iter() + .find(|(id, _)| *id == producer.id) + .map(|(_, d)| *d) + .unwrap_or(0); + d = d.max(pd + 1); + } + } + depth.push((c.id, d)); + } + let mut rows_in_col: Vec = Vec::new(); + let mut boxes: Vec = Vec::with_capacity(cmds.len()); + for (c, &(_, col)) in cmds.iter().zip(&depth) { + while rows_in_col.len() <= col { + rows_in_col.push(0); + } + let row = rows_in_col[col]; + rows_in_col[col] += 1; + let h = if c.collapsed { COLLAPSED_H } else { NODE_H }; + let x = ORIGIN_X + col as f32 * (NODE_W + COL_GAP); + let y = ORIGIN_Y + row as f32 * (NODE_H + ROW_GAP); + boxes.push(LaidBox { + id: c.id, + label: c.intention.clone(), + status: c.status, + collapsed: c.collapsed, + x, + y, + w: NODE_W, + h, + }); + } + (boxes, edges) +} + +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + let (boxes, edges) = layout(&state.graph); + // El painter y la closure de on_click_at ambos consumen `boxes`; + // pre-clonamos para alimentar al click_handler antes de mover el + // vector al painter. + let hit_boxes = boxes.clone(); + let theme_clone = *theme; + let scroll_y = state.scroll_y as f64; + let focused_id = state.focused; + let header_label = format!( + "Lienzo de contexto · {} comandos · {} aristas", + boxes.len(), + edges.len() + ); + + let painter = move |scene: &mut vello::Scene, + ts: &mut llimphi_ui::llimphi_text::Typesetter, + rect: llimphi_ui::PaintRect| { + use llimphi_ui::llimphi_raster::kurbo::{BezPath, PathEl, Point as KPt, Rect as KRect}; + use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; + use llimphi_ui::llimphi_text::{draw_layout, layout_block, Alignment as TAlign, TextBlock}; + // Aristas primero (al fondo) — Bezier suaves entre el borde + // derecho del producer y el borde izquierdo del consumer. + let stroke_color = Color::from_rgba8(107, 114, 128, 200); + for e in &edges { + let from = boxes.iter().find(|b| b.id == e.from); + let to = boxes.iter().find(|b| b.id == e.to); + let (Some(a), Some(b)) = (from, to) else { continue }; + let ax = rect.x as f64 + a.x as f64 + a.w as f64; + let ay = rect.y as f64 + a.y as f64 + a.h as f64 * 0.5 - scroll_y; + let bx = rect.x as f64 + b.x as f64; + let by = rect.y as f64 + b.y as f64 + b.h as f64 * 0.5 - scroll_y; + let dx = (bx - ax) * 0.5; + let path = BezPath::from_vec(vec![ + PathEl::MoveTo(KPt::new(ax, ay)), + PathEl::CurveTo( + KPt::new(ax + dx, ay), + KPt::new(bx - dx, by), + KPt::new(bx, by), + ), + ]); + scene.stroke( + &llimphi_ui::llimphi_raster::kurbo::Stroke::new(1.5), + vello::kurbo::Affine::IDENTITY, + stroke_color, + None, + &path, + ); + } + // Cajas: fill + stroke por status + label. + for b in &boxes { + let x0 = rect.x as f64 + b.x as f64; + let y0 = rect.y as f64 + b.y as f64 - scroll_y; + let krect = KRect::new(x0, y0, x0 + b.w as f64, y0 + b.h as f64); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + theme_clone.bg_panel, + None, + &krect, + ); + let status_color = match b.status { + NodeStatus::Running => Color::from_rgba8(0xe0, 0xb3, 0x41, 255), + NodeStatus::Ok => Color::from_rgba8(0x4c, 0xaf, 0x6a, 255), + NodeStatus::Failed => Color::from_rgba8(0xd0, 0x46, 0x3b, 255), + }; + // Stroke por status. Si el nodo está enfocado va el doble + // de grueso para destacar. + let stroke_w = if focused_id == Some(b.id) { 3.5 } else { 2.0 }; + scene.stroke( + &llimphi_ui::llimphi_raster::kurbo::Stroke::new(stroke_w), + vello::kurbo::Affine::IDENTITY, + status_color, + None, + &krect, + ); + // Label: `%cN ` (trunca si no entra). + let label = format!("%c{} {}", b.id, b.label); + let truncated = truncate_to_fit(&label, b.w as usize / 7); + let block = TextBlock { + text: &truncated, + size_px: 11.0, + color: theme_clone.fg_text, + origin: (x0 + 8.0, y0 + 6.0), + max_width: Some(b.w - 16.0), + alignment: TAlign::Start, + line_height: 1.2, + italic: false, + font_family: None, + }; + let layout = layout_block(ts, &block); + draw_layout(scene, &layout, theme_clone.fg_text, (x0 + 8.0, y0 + 6.0)); + // Status text (más chico, abajo) — solo si no está colapsado. + if !b.collapsed { + let status_str = match b.status { + NodeStatus::Running => "● running", + NodeStatus::Ok => "✔ ok", + NodeStatus::Failed => "✘ failed", + }; + let sblock = TextBlock { + text: status_str, + size_px: 10.0, + color: status_color, + origin: (x0 + 8.0, y0 + b.h as f64 - 18.0), + max_width: Some(b.w - 16.0), + alignment: TAlign::Start, + line_height: 1.0, + italic: false, + font_family: None, + }; + let slayout = layout_block(ts, &sblock); + draw_layout( + scene, + &slayout, + status_color, + (x0 + 8.0, y0 + b.h as f64 - 18.0), + ); + } + } + }; + + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(24.0_f32), + }, + ..Default::default() + }) + .text_aligned(header_label, 12.0, theme.fg_muted, Alignment::Start); + + // hit_test_box devuelve qué `%cN` cayó bajo el cursor en + // coordenadas locales del canvas (paint_with usa las mismas + // (b.x, b.y) corregidas por scroll_y). Si nada matchea, emitimos + // `NodeClicked(None)` para desfocar. + let lift_click = lift.clone(); + let scroll_y_f32 = state.scroll_y; + let canvas = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .radius(3.0) + .paint_with(painter) + .on_click_at(move |lx, ly, _w, _h| { + let hit = hit_test_box(&hit_boxes, lx, ly, scroll_y_f32); + Some(lift_click(Msg::NodeClicked(hit))) + }); + + let detail = focused_id + .and_then(|id| state.graph.commands().iter().find(|c| c.id == id).cloned()) + .map(|node| focus_detail::(&node, theme, lift.clone())); + + let mut children = vec![header, canvas]; + if let Some(d) = detail { + children.push(d); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(6.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(children) +} + +/// Hit-test puro: dado `(lx, ly)` en coordenadas locales del rect del +/// canvas + el `scroll_y` activo, devuelve el `%cN` del nodo que cubre +/// ese punto, o `None`. Si dos cajas se superponen (no debería con el +/// layout columnar), se devuelve la primera del orden de inserción. +fn hit_test_box(boxes: &[LaidBox], lx: f32, ly: f32, scroll_y: f32) -> Option { + let y_world = ly + scroll_y; + boxes + .iter() + .find(|b| lx >= b.x && lx <= b.x + b.w && y_world >= b.y && y_world <= b.y + b.h) + .map(|b| b.id) +} + +/// Tira inferior con el detalle del nodo enfocado: intención completa, +/// status, bytes producidos + dos botones que piden insertar `%cN` o +/// `%pN` en el input del shell vía `Msg::InsertRef`. +fn focus_detail( + node: &shuma_intent::CommandNode, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_layout::taffy::AlignItems; + + let status_label = match node.status { + NodeStatus::Running => "● running", + NodeStatus::Ok => "✔ ok", + NodeStatus::Failed => "✘ failed", + }; + let header_text = format!( + "%c{} {} {} {} B", + node.id, node.intention, status_label, node.output_bytes + ); + + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + ..Default::default() + }) + .text_aligned(header_text, 12.0, theme.fg_text, Alignment::Start); + + let cn_ref = format!("%c{}", node.id); + let pn_ref = node.output_buffer.map(|n| format!("%p{}", n)); + + let lift_cn = lift.clone(); + let cn_button = View::new(Style { + size: Size { + width: length(110.0_f32), + height: length(24.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(4.0) + .text_aligned( + format!("Insertar {cn_ref}"), + 11.0, + theme.fg_text, + Alignment::Center, + ) + .on_click(lift_cn(Msg::InsertRef(cn_ref))); + + let mut row: Vec> = vec![cn_button]; + if let Some(pref) = pn_ref { + let lift_pn = lift.clone(); + let label = format!("Insertar {pref}"); + let pn_button = View::new(Style { + size: Size { + width: length(110.0_f32), + height: length(24.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + margin: Rect { + left: length(6.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(4.0) + .text_aligned(label, 11.0, theme.fg_text, Alignment::Center) + .on_click(lift_pn(Msg::InsertRef(pref))); + row.push(pn_button); + } + + let buttons = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(row); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: length(58.0_f32), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(4.0_f32), + bottom: length(4.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(4.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(3.0) + .children(vec![header, buttons]) +} + +/// Trunca `s` a `max_chars` chars (caracteres, no bytes), agregando `…`. +fn truncate_to_fit(s: &str, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); + } + if s.chars().count() <= max_chars { + return s.to_string(); + } + let cut: String = s.chars().take(max_chars.saturating_sub(1)).collect(); + format!("{cut}…") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_is_stable() { + assert_eq!(ID, "canvas"); + } + + #[test] + fn demo_state_has_four_nodes() { + let s = State::demo(); + assert_eq!(s.graph.commands().len(), 4); + } + + #[test] + fn record_msg_adds_a_node() { + let s = State::new(); + let s = update(s, Msg::Record("ls -la".into())); + assert_eq!(s.graph.commands().len(), 1); + assert_eq!(s.graph.commands()[0].intention, "ls -la"); + } + + #[test] + fn complete_msg_assigns_status_and_buffer() { + let mut s = State::new(); + let id = s.graph.record("echo hola"); + let s = update( + s, + Msg::Complete { + id, + ok: true, + bytes: 5, + }, + ); + let node = &s.graph.commands()[0]; + assert_eq!(node.status, NodeStatus::Ok); + assert!(node.output_buffer.is_some()); + assert_eq!(node.output_bytes, 5); + } + + #[test] + fn collapse_msg_retracts_successful_nodes() { + let s = State::demo(); + let s = update(s, Msg::CollapseSucceeded); + let ok_nodes: Vec<_> = s + .graph + .commands() + .iter() + .filter(|c| c.status == NodeStatus::Ok) + .collect(); + assert!(!ok_nodes.is_empty()); + assert!(ok_nodes.iter().all(|c| c.collapsed)); + } + + #[test] + fn reset_msg_empties_graph() { + let s = State::demo(); + let s = update(s, Msg::Reset); + assert!(s.graph.is_empty()); + } + + #[test] + fn sync_graph_replaces_state() { + // El chasis empuja el snapshot del shell — el canvas adopta el + // grafo entero sin reconciliar nodo por nodo. + let s = State::demo(); + assert_eq!(s.graph.commands().len(), 4); + let mut other = SessionGraph::new(); + other.record("ls -la"); + let s = update(s, Msg::SyncGraph(other)); + assert_eq!(s.graph.commands().len(), 1); + assert_eq!(s.graph.commands()[0].intention, "ls -la"); + } + + #[test] + fn node_clicked_focuses_and_second_click_toggles() { + let s = State::demo(); + assert!(s.focused.is_none()); + let s = update(s, Msg::NodeClicked(Some(2))); + assert_eq!(s.focused, Some(2)); + // Mismo id otra vez → toggle off. + let s = update(s, Msg::NodeClicked(Some(2))); + assert!(s.focused.is_none()); + } + + #[test] + fn node_clicked_outside_clears_focus() { + let mut s = State::demo(); + s.focused = Some(1); + let s = update(s, Msg::NodeClicked(None)); + assert!(s.focused.is_none()); + } + + #[test] + fn sync_graph_clears_focus_if_node_dropped() { + let mut s = State::demo(); + s.focused = Some(3); + let s = update(s, Msg::SyncGraph(SessionGraph::new())); + assert!( + s.focused.is_none(), + "el nodo desapareció — el foco debe limpiarse" + ); + } + + #[test] + fn hit_test_finds_box_under_cursor() { + let s = State::demo(); + let (boxes, _) = layout(&s.graph); + // El primer comando (`%c1`, "cat data.json") está en la + // columna 0 → x = 16.0, y = 16.0, ancho 160, alto 56. + let c1 = &boxes[0]; + let cx = c1.x + c1.w * 0.5; + let cy = c1.y + c1.h * 0.5; + assert_eq!(hit_test_box(&boxes, cx, cy, 0.0), Some(c1.id)); + // Click fuera del rect — None. + assert!(hit_test_box(&boxes, 1000.0, 1000.0, 0.0).is_none()); + } + + #[test] + fn hit_test_respects_scroll_offset() { + let s = State::demo(); + let (boxes, _) = layout(&s.graph); + let c1 = &boxes[0]; + let cx = c1.x + c1.w * 0.5; + let cy = c1.y + c1.h * 0.5; + // Con un scroll positivo, el cursor en `cy` apuntaría al espacio + // *encima* del nodo (cy + scroll_y > b.y + b.h cuando scroll>0) + // — el hit-test debería fallar si seguimos clickeando en la misma + // coord local sin compensar. + let scrolled_local_y = cy - 80.0; // simulamos que el nodo se movió arriba + assert_eq!( + hit_test_box(&boxes, cx, scrolled_local_y, 80.0), + Some(c1.id) + ); + } + + #[test] + fn insert_ref_msg_is_noop_on_canvas_state() { + // Sin chasis, `InsertRef` no debería mutar el canvas — el chasis + // intercepta esta variante. Garantiza que canvas standalone no + // se rompe si la variante se le cuela. + let s = State::demo(); + let before = (s.graph.commands().len(), s.focused); + let s = update(s, Msg::InsertRef("%p1".into())); + assert_eq!(s.graph.commands().len(), before.0); + assert_eq!(s.focused, before.1); + } + + #[test] + fn layout_places_dependents_in_higher_columns() { + let s = State::demo(); + let (boxes, _) = layout(&s.graph); + // c1 (cat data.json) está en col 0; c2 (jq … %p1) en col 1; + // c3 (grep … %p2) en col 2; c4 (sort %p2 | head) también en col 2. + let c1 = boxes.iter().find(|b| b.id == 1).unwrap(); + let c2 = boxes.iter().find(|b| b.id == 2).unwrap(); + let c3 = boxes.iter().find(|b| b.id == 3).unwrap(); + assert!(c1.x < c2.x); + assert!(c2.x < c3.x); + } + + #[test] + fn truncate_to_fit_respects_limit() { + assert_eq!(truncate_to_fit("hola", 10), "hola"); + let t = truncate_to_fit("comando muy largo aquí", 8); + assert_eq!(t.chars().count(), 8); + assert!(t.ends_with('…')); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-commandbar/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-commandbar/Cargo.toml new file mode 100644 index 0000000..687ad35 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-commandbar/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shuma-module-commandbar" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-module-commandbar — barra inferior fija (Placement::BottomBar) con input de doble modo (launcher por default, shell con tecla). Placeholder con la línea visual; el cableado real al run-dialog y al REPL llega aparte." + +[dependencies] +shuma-module = { path = "../shuma-module" } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +nucleo-matcher = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-commandbar/LEEME.md b/02_ruway/shuma/sandbox/shuma-module-commandbar/LEEME.md new file mode 100644 index 0000000..ce81450 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-commandbar/LEEME.md @@ -0,0 +1,9 @@ +# shuma-module-commandbar + +> Módulo command bar (TopBar) de [shuma](../../README.md). + +Slot superior: search global, switcher de sesión, breadcrumb, status. Atajo `Ctrl+K` foca. + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`llimphi-widget-text-input`](../../../llimphi/widgets/text-input/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-commandbar/README.md b/02_ruway/shuma/sandbox/shuma-module-commandbar/README.md new file mode 100644 index 0000000..b1309ab --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-commandbar/README.md @@ -0,0 +1,9 @@ +# shuma-module-commandbar + +> Command bar module (TopBar) of [shuma](../../README.md). + +Top slot: global search, session switcher, breadcrumb, status. Shortcut `Ctrl+K` focuses. + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`llimphi-widget-text-input`](../../../llimphi/widgets/text-input/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs new file mode 100644 index 0000000..f3d8b15 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-commandbar/src/lib.rs @@ -0,0 +1,512 @@ +//! `shuma-module-commandbar` — palette tipo Cmd-P en la barra inferior. +//! +//! Vive en el slot [`Placement::BottomBar`] del chasis. El usuario +//! tipea para buscar contra un **catálogo** de comandos (focus a un +//! tab, abrir el launcher, ejecutar una línea); los matches se +//! puntúan con fuzzy (`nucleo_matcher`) y se listan en un dropdown +//! sobre la barra. Up/Down navegan, Enter activa el seleccionado. +//! +//! El catálogo lo provee el chasis vía [`State::set_catalog`] — +//! típicamente son los `[apps]` del shumarc + los `[modules]` +//! activos + un par de built-ins (`> reload-config`, `> theme`, …). +//! +//! El modo dual (launcher/shell) se mantiene: en modo `Shell`, Enter +//! emite `Activated(Exec(text))` para que el chasis lo enrute al +//! módulo shell; en modo `Launcher`, Enter activa el match +//! seleccionado del dropdown. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, AlignItems, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_theme::Theme; +use nucleo_matcher::{ + pattern::{CaseMatching, Normalization, Pattern}, + Matcher, +}; +use shuma_module::{ModuleContributions, Placement}; + +/// `id` canónico del módulo. +pub const ID: &str = "command-bar"; + +/// `Placement` por defecto: barra inferior fija. +pub const DEFAULT_PLACEMENT: Placement = Placement::BottomBar; + +/// Contexto del input. En `Launcher` se busca el catálogo; +/// en `Shell` se ejecuta como línea de shell (el chasis hace el ruteo). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Mode { + #[default] + Launcher, + Shell, +} + +impl Mode { + pub fn label(self) -> &'static str { + match self { + Mode::Launcher => "launcher", + Mode::Shell => "shell", + } + } + pub fn prompt(self) -> &'static str { + match self { + Mode::Launcher => "›", + Mode::Shell => "$", + } + } + pub fn toggle(self) -> Self { + match self { + Mode::Launcher => Mode::Shell, + Mode::Shell => Mode::Launcher, + } + } +} + +/// Una entrada del catálogo de comandos. El `kind` decide qué hace +/// el chasis al activarla. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandEntry { + /// Texto que se busca y se muestra. + pub label: String, + /// Categoría (App, Module, Builtin, …) — sólo para subtítulo. + pub category: String, + /// Acción al activarla. + pub kind: CommandKind, +} + +/// Acción asociada a una entry — opaca para el módulo, interpretada +/// por el chasis. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandKind { + /// Focar el tab del drawer cuyo `Kind::id()` es `target`. + FocusTab(String), + /// Lanzar una línea (spawn detached o exec en shell según el + /// chasis decida — el módulo no sabe). + Exec(String), + /// Dispatch genérico (`open:files`, `theme:next`, etc.) — + /// resuelto por la tabla de shortcuts del chasis. + Action(String), +} + +/// Estado del módulo. +#[derive(Debug, Clone, Default)] +pub struct State { + pub text: String, + pub mode: Mode, + /// Catálogo provisionado por el chasis (típicamente al arranque + /// y cuando cambia el shumarc). + pub catalog: Vec, + /// Índice seleccionado dentro de la lista de matches actuales. + pub selected: usize, +} + +impl State { + pub fn set_catalog(&mut self, catalog: Vec) { + self.catalog = catalog; + self.selected = 0; + } + + /// Devuelve los índices de `catalog` que matchean `self.text`, + /// ordenados por score descendente. Limita a `limit` resultados. + pub fn matches(&self, limit: usize) -> Vec { + if self.text.trim().is_empty() { + // Sin query: todos los catalog en orden de declaración. + return (0..self.catalog.len()).take(limit).collect(); + } + let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT); + let pattern = Pattern::parse( + self.text.trim(), + CaseMatching::Smart, + Normalization::Smart, + ); + let mut scored: Vec<(usize, u32)> = self + .catalog + .iter() + .enumerate() + .filter_map(|(i, e)| { + pattern + .score( + nucleo_matcher::Utf32String::from(e.label.as_str()).slice(..), + &mut matcher, + ) + .map(|s| (i, s)) + }) + .collect(); + scored.sort_by(|a, b| b.1.cmp(&a.1)); + scored.into_iter().take(limit).map(|(i, _)| i).collect() + } +} + +#[derive(Debug, Clone)] +pub enum Msg { + /// Tecla recibida desde el chasis. Texto, Backspace, Up/Down y + /// Enter se procesan acá. + Key(KeyEvent), + /// El usuario togglea el modo (Ctrl+grave o similar). + ToggleMode, + /// Click en una row del dropdown — el `usize` es el `catalog_idx`. + ActivateAt(usize), + /// Click sobre la barra (no en el dropdown). El chasis lo + /// intercepta para abrir el drawer Quake; el módulo no lo procesa. + BarClicked, +} + +/// El chasis observa este `Activated` después de llamar `update` y +/// dispatchea según el `CommandKind` (focar tab, lanzar binario, +/// etc.). El módulo deja `last_activated` lleno por un solo Tick. +#[derive(Debug, Clone, Default)] +pub struct Activation { + pub kind: Option, +} + +pub fn dispatch(_action_id: &str) -> Option { + None +} + +pub fn update(state: State, msg: Msg) -> State { + let mut s = state; + match msg { + Msg::Key(ev) => { + if ev.state != KeyState::Pressed { + return s; + } + match &ev.key { + Key::Named(NamedKey::Backspace) => { + s.text.pop(); + s.selected = 0; + } + Key::Named(NamedKey::ArrowDown) => { + let max = s.matches(50).len(); + if max > 0 && s.selected + 1 < max { + s.selected += 1; + } + } + Key::Named(NamedKey::ArrowUp) => { + s.selected = s.selected.saturating_sub(1); + } + Key::Named(NamedKey::Escape) => { + s.text.clear(); + s.selected = 0; + } + Key::Named(NamedKey::Enter) => { + // Enter NO clear-ea acá — el chasis intercepta el + // submit y limpia tras procesar el Activation. + } + _ => { + if let Some(text) = &ev.text { + if !text.is_empty() + && !text.chars().any(|c| c.is_control()) + { + s.text.push_str(text); + s.selected = 0; + } + } + } + } + } + Msg::ToggleMode => { + s.mode = s.mode.toggle(); + s.selected = 0; + } + Msg::ActivateAt(idx) => { + s.selected = idx; + } + Msg::BarClicked => {} + } + s +} + +/// Devuelve la acción "activada" cuando el usuario presiona Enter o +/// hace click en una row del dropdown. El chasis la consume y limpia +/// el state vía `clear_after_activation`. +pub fn activation_for(state: &State, ev: &KeyEvent) -> Option { + if !matches!(ev.key, Key::Named(NamedKey::Enter)) { + return None; + } + if ev.state != KeyState::Pressed { + return None; + } + match state.mode { + Mode::Shell => { + // En modo shell, Enter ejecuta la línea tal cual. + if state.text.trim().is_empty() { + None + } else { + Some(CommandKind::Exec(state.text.clone())) + } + } + Mode::Launcher => { + // En modo launcher, Enter activa el match seleccionado. + let matches = state.matches(50); + matches + .get(state.selected) + .and_then(|i| state.catalog.get(*i)) + .map(|e| e.kind.clone()) + } + } +} + +/// Llamar después de procesar una activación: limpia el texto y +/// resetea el cursor. Mantiene el catalog y el mode. +pub fn clear_after_activation(state: State) -> State { + let mut s = state; + s.text.clear(); + s.selected = 0; + s +} + +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + 'static + Clone, +) -> View { + let prompt = format!("{} ", state.mode.prompt()); + let placeholder = format!( + "{}escribí — Enter ejecuta · Ctrl+` cambia a {}", + prompt, + state.mode.toggle().label() + ); + let display_text = if state.text.is_empty() { + placeholder + } else { + format!("{}{}", prompt, state.text) + }; + + let bar = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel) + .text_aligned( + display_text, + 12.0, + if state.text.is_empty() { + theme.fg_muted + } else { + theme.fg_text + }, + Alignment::Start, + ) + .on_click(lift.clone()(Msg::BarClicked)); + + // Dropdown sólo en modo Launcher con texto no vacío. + if !matches!(state.mode, Mode::Launcher) || state.text.is_empty() { + return bar; + } + let matches = state.matches(8); + if matches.is_empty() { + return bar; + } + + let mut rows: Vec> = Vec::with_capacity(matches.len()); + for (row_i, cat_i) in matches.iter().enumerate() { + let Some(entry) = state.catalog.get(*cat_i) else { + continue; + }; + let is_selected = row_i == state.selected; + let bg = if is_selected { + theme.bg_selected + } else { + theme.bg_panel + }; + let fg = if is_selected { + theme.fg_text + } else { + theme.fg_muted + }; + let label_row = format!(" {} ({})", entry.label, entry.category); + let idx = *cat_i; + let lift_row = lift.clone(); + rows.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .text_aligned(label_row, 12.0, fg, Alignment::Start) + .on_click(lift_row(Msg::ActivateAt(idx))), + ); + } + let dropdown = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(rows); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + ..Default::default() + }) + .children(vec![dropdown, bar]) +} + +pub fn contributions(_state: &State) -> ModuleContributions { + ModuleContributions::empty() +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_ui::Modifiers; + + fn ev(key: Key, text: Option<&str>) -> KeyEvent { + KeyEvent { + key, + state: KeyState::Pressed, + text: text.map(|s| s.to_string()), + modifiers: Modifiers::default(), + repeat: false, + } + } + + fn fixture_catalog() -> Vec { + vec![ + CommandEntry { + label: "Pluma editor".into(), + category: "app".into(), + kind: CommandKind::Exec("pluma-app".into()), + }, + CommandEntry { + label: "Focus shell".into(), + category: "module".into(), + kind: CommandKind::FocusTab("shell".into()), + }, + CommandEntry { + label: "Focus matilda".into(), + category: "module".into(), + kind: CommandKind::FocusTab("matilda".into()), + }, + ] + } + + #[test] + fn id_is_stable() { + assert_eq!(ID, "command-bar"); + } + + #[test] + fn default_placement_is_bottombar() { + assert_eq!(DEFAULT_PLACEMENT, Placement::BottomBar); + } + + #[test] + fn typing_filters_catalog() { + let mut s = State::default(); + s.set_catalog(fixture_catalog()); + // Tipear "shell" debería poner "Focus shell" arriba. + for c in "shell".chars() { + let mut buf = [0u8; 4]; + s = update( + s, + Msg::Key(ev(Key::Character(c.to_string().into()), Some(c.encode_utf8(&mut buf)))), + ); + } + let m = s.matches(5); + assert!(!m.is_empty()); + assert_eq!(s.catalog[m[0]].label, "Focus shell"); + } + + #[test] + fn arrow_down_moves_selection() { + let mut s = State::default(); + s.set_catalog(fixture_catalog()); + // Sin filtro: 3 matches en orden de declaración. + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); + assert_eq!(s.selected, 1); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); + assert_eq!(s.selected, 2); + // No pasa el límite. + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); + assert_eq!(s.selected, 2); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); + assert_eq!(s.selected, 1); + } + + #[test] + fn enter_in_launcher_activates_selected() { + let mut s = State::default(); + s.set_catalog(fixture_catalog()); + // selected = 0 → "Pluma editor" + let enter = ev(Key::Named(NamedKey::Enter), None); + let kind = activation_for(&s, &enter).expect("activación"); + assert!(matches!(kind, CommandKind::Exec(ref l) if l == "pluma-app")); + } + + #[test] + fn enter_in_shell_returns_exec_with_text() { + let mut s = State::default(); + s.mode = Mode::Shell; + s.text = "ls -la".into(); + let enter = ev(Key::Named(NamedKey::Enter), None); + let kind = activation_for(&s, &enter).expect("activación"); + assert!(matches!(kind, CommandKind::Exec(ref l) if l == "ls -la")); + } + + #[test] + fn escape_clears_text() { + let mut s = State::default(); + s.text = "hola".into(); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Escape), None))); + assert!(s.text.is_empty()); + } + + #[test] + fn toggle_mode_flips_only_mode() { + let mut s = State::default(); + s.text = "ls".into(); + let s = update(s, Msg::ToggleMode); + assert_eq!(s.mode, Mode::Shell); + assert_eq!(s.text, "ls"); + } + + #[test] + fn clear_after_activation_resets_text_keeps_catalog() { + let mut s = State::default(); + s.set_catalog(fixture_catalog()); + s.text = "hola".into(); + let s = clear_after_activation(s); + assert!(s.text.is_empty()); + assert_eq!(s.catalog.len(), 3); + } + + #[test] + fn matches_returns_all_with_empty_query() { + let mut s = State::default(); + s.set_catalog(fixture_catalog()); + let m = s.matches(10); + assert_eq!(m.len(), 3); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-launcher/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-launcher/Cargo.toml new file mode 100644 index 0000000..61d2246 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-launcher/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "shuma-module-launcher" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-module-launcher — barra superior fija (Placement::TopBar) con grid de apps + shortcuts. Placeholder por ahora; la integración real con mirada-launcher llega aparte." + +[dependencies] +shuma-module = { path = "../shuma-module" } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-launcher/LEEME.md b/02_ruway/shuma/sandbox/shuma-module-launcher/LEEME.md new file mode 100644 index 0000000..7b00df1 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-launcher/LEEME.md @@ -0,0 +1,9 @@ +# shuma-module-launcher + +> Módulo launcher (DrawerTab) de [shuma](../../README.md). + +Slot del drawer Quake: `F12` lo despliega, fuzzy-finder de comandos y apps recientes. Wrapper sobre [`mirada-launcher`](../../../mirada/mirada-launcher/README.md). + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`mirada-launcher`](../../../mirada/mirada-launcher/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-launcher/README.md b/02_ruway/shuma/sandbox/shuma-module-launcher/README.md new file mode 100644 index 0000000..c7e12ef --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-launcher/README.md @@ -0,0 +1,9 @@ +# shuma-module-launcher + +> Launcher module (DrawerTab) of [shuma](../../README.md). + +Quake drawer slot: `F12` deploys it, fuzzy-finder of commands and recent apps. Wraps [`mirada-launcher`](../../../mirada/mirada-launcher/README.md). + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`mirada-launcher`](../../../mirada/mirada-launcher/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs new file mode 100644 index 0000000..44eeff0 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-launcher/src/lib.rs @@ -0,0 +1,372 @@ +//! `shuma-module-launcher` — barra superior fija con apps/shortcuts. +//! +//! Vive en el slot [`Placement::TopBar`] del chasis: una tira corta +//! con accesos directos. Las entries se leen del filesystem: +//! +//! ```text +//! $XDG_CONFIG_HOME/shuma/apps/*.toml +//! ``` +//! +//! Cada `.toml` declara una entry: +//! +//! ```toml +//! label = "Pluma" +//! exec = "pluma-app" # opcional; si está, click → spawn detached +//! action_id = "focus:pluma" # opcional; si no hay exec, el chasis lo dispatchea +//! ``` +//! +//! Si `~/.config/shuma/apps/` no existe (o está vacío), el launcher +//! cae al `State::demo()` con tres entries fijas (Files/Shell/Matilda) +//! para que el chasis sea exploratorio desde el día uno. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + AlignItems, JustifyContent, Rect, +}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::Theme; +use shuma_module::{ModuleContributions, Placement, ShortcutSpec}; + +/// `id` canónico del módulo. +pub const ID: &str = "launcher"; + +/// `Placement` por defecto del módulo. El shumarc puede overrideearlo +/// (p. ej. ponerlo como `DrawerTab` para tenerlo dentro del overlay +/// Quake), pero su lugar natural es la barra superior. +pub const DEFAULT_PLACEMENT: Placement = Placement::TopBar; + +/// Estado del módulo. En el placeholder lleva un buffer mínimo: la +/// app que se está hovereando, si hay. Cuando llegue la integración +/// real, aquí vivirán los `[apps]` cargados del shumarc. +#[derive(Debug, Clone, Default)] +pub struct State { + /// Lista de entradas del launcher (label + acción al click). + pub entries: Vec, +} + +/// Una entrada del launcher: label, opcional `exec` para spawn +/// detached al click, opcional `action_id` que el chasis dispatchea. +/// Al menos uno de los dos debe estar (el loader rechaza el manifest +/// si los dos están vacíos). +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct LauncherEntry { + pub label: String, + /// Programa a ejecutar (parseo simple por whitespace: el primer + /// token es el binario, el resto son args). Si está, el click hace + /// spawn detached vía `process_group(0)`. + #[serde(default)] + pub exec: Option, + /// Acción opaca al chasis (focus:shell, open:files, etc.). Si no + /// hay `exec`, el chasis decide qué hacer (focus a un tab, abrir + /// un módulo, etc.). Default `""` cuando hay `exec`. + #[serde(default)] + pub action_id: String, +} + +impl LauncherEntry { + pub fn new(label: impl Into, action_id: impl Into) -> Self { + Self { + label: label.into(), + exec: None, + action_id: action_id.into(), + } + } + + pub fn with_exec(label: impl Into, exec: impl Into) -> Self { + Self { + label: label.into(), + exec: Some(exec.into()), + action_id: String::new(), + } + } +} + +impl State { + /// State de demo con entries fijas: Files / Shell / Matilda. El + /// loader real las reemplaza si encuentra manifests en disco. + pub fn demo() -> Self { + Self { + entries: vec![ + LauncherEntry::new("Files", "open:files"), + LauncherEntry::new("Shell", "focus:shell"), + LauncherEntry::new("Matilda", "focus:matilda"), + ], + } + } + + /// Lee `$XDG_CONFIG_HOME/shuma/apps/*.toml` (orden alfabético) y + /// arma las entries. Si el dir no existe o no hay manifests + /// válidos, devuelve `State::demo()` — el chasis arranca usable. + pub fn from_apps_dir() -> Self { + let Some(dir) = apps_dir() else { + return Self::demo(); + }; + let entries = load_entries_from_dir(&dir); + if entries.is_empty() { + Self::demo() + } else { + Self { entries } + } + } +} + +fn apps_dir() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")) + })?; + Some(base.join("shuma").join("apps")) +} + +/// Lee `.toml` del dir como `LauncherEntry` y los devuelve ordenados. +/// Manifests inválidos se omiten silenciosamente (un launcher no debe +/// fallar el shell por un toml roto). +pub fn load_entries_from_dir(dir: &std::path::Path) -> Vec { + let Ok(rd) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut paths: Vec = rd + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("toml")) + .collect(); + paths.sort(); + let mut out: Vec = Vec::new(); + for p in paths { + let Ok(text) = std::fs::read_to_string(&p) else { + continue; + }; + let Ok(entry) = toml::from_str::(&text) else { + continue; + }; + if entry.exec.is_none() && entry.action_id.is_empty() { + continue; + } + out.push(entry); + } + out +} + +/// Mensajes del módulo. +#[derive(Debug, Clone)] +pub enum Msg { + /// Click en una entry; lleva el `action_id` para que el chasis lo + /// resuelva (típicamente buscando un módulo con ese id, o lanzando + /// el comando si es `cmd:...`). Sólo se emite cuando la entry NO + /// tiene `exec` propio — si tenía, el launcher ya lo spawneó y el + /// chasis no necesita hacer nada. + EntryClicked(String), +} + +pub fn update(state: State, _msg: Msg) -> State { + state +} + +/// Spawnea el `exec` de una entry detached del shell. Parseo simple +/// por whitespace; quoting avanzado no soportado (un launcher quiere +/// invocar binarios, no scripts). +pub fn spawn_exec(exec_line: &str) { + use std::os::unix::process::CommandExt; + let mut parts = exec_line.split_whitespace(); + let Some(program) = parts.next() else { + return; + }; + let args: Vec<&str> = parts.collect(); + let _ = std::process::Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .process_group(0) + .spawn(); +} + +/// Mapea `action_id` a `Msg`. El launcher expone `launcher.toggle` como +/// acción global que el chasis consume directamente (toggle de la +/// TopBar autohide); ningún `action_id` produce un `Msg` propio del +/// launcher todavía. +pub fn dispatch(_action_id: &str) -> Option { + None +} + +/// Renderiza la barra superior: el label "shuma" a la izquierda y los +/// botones de entries a la derecha (compactos, alto fijo). Aplica el +/// alto de la app-header global (40 px) para que cuadre con el resto +/// de las apps Llimphi. +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + 'static + Clone, +) -> View { + let brand = View::new(Style { + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + padding: Rect { + left: length(16.0_f32), + right: length(16.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned("shuma".to_string(), 13.0, theme.fg_text, Alignment::Start); + + let mut children: Vec> = vec![brand]; + for entry in &state.entries { + let lift = lift.clone(); + let action_id = entry.action_id.clone(); + let exec = entry.exec.clone(); + children.push(entry_button(entry.label.clone(), theme, move || { + // Si la entry tiene `exec`, lanzamos detached y devolvemos + // un msg neutral (el chasis lo ve como "no hagas nada"). + // Si no, emitimos EntryClicked para que el chasis lo + // resuelva via su tabla de dispatch. + if let Some(line) = exec.clone() { + spawn_exec(&line); + lift(Msg::EntryClicked(String::new())) + } else { + lift(Msg::EntryClicked(action_id.clone())) + } + })); + } + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(40.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel) + .children(children) +} + +fn entry_button( + label: String, + theme: &Theme, + on_click: impl FnOnce() -> HostMsg, +) -> View { + let msg = on_click(); + View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(28.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + margin: Rect { + left: length(0.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(4.0) + .text_aligned(label, 12.0, theme.fg_text, Alignment::Center) + .on_click(msg) +} + +/// Por consistencia con `Color::accent`. No usado en el placeholder +/// pero referenciado para que pase clippy si el bloque siguiente lo +/// llama desde un panel de "recent apps" o similar. +#[allow(dead_code)] +fn _accent_unused(theme: &Theme) -> Color { + theme.accent +} + +/// Contribuciones: el launcher mismo aporta un shortcut al toolbar +/// general ("Apps") que es redundante con la TopBar pero útil cuando +/// el launcher está oculto (TopBar autohide). +pub fn contributions(_state: &State) -> ModuleContributions { + ModuleContributions { + monitors: Vec::new(), + shortcuts: vec![ShortcutSpec::module_action("Apps", "launcher.toggle") + .with_hint("Abrir el launcher de apps")], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_is_stable() { + assert_eq!(ID, "launcher"); + } + + #[test] + fn default_placement_is_topbar() { + assert_eq!(DEFAULT_PLACEMENT, Placement::TopBar); + } + + #[test] + fn demo_state_has_three_entries() { + let s = State::demo(); + assert_eq!(s.entries.len(), 3); + assert_eq!(s.entries[0].label, "Files"); + assert_eq!(s.entries[1].action_id, "focus:shell"); + } + + #[test] + fn contributions_expose_apps_shortcut() { + let s = State::default(); + let c = contributions(&s); + assert_eq!(c.shortcuts.len(), 1); + assert_eq!(c.shortcuts[0].label, "Apps"); + } + + #[test] + fn entry_clicked_message_carries_action_id() { + let m = Msg::EntryClicked("focus:matilda".into()); + match m { + Msg::EntryClicked(id) => assert_eq!(id, "focus:matilda"), + } + } + + #[test] + fn load_entries_from_dir_reads_toml_manifests() { + let dir = std::env::temp_dir().join(format!( + "shuma-launcher-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("01-pluma.toml"), + "label = \"Pluma\"\nexec = \"pluma-app\"\n", + ) + .unwrap(); + std::fs::write( + dir.join("02-shell.toml"), + "label = \"Shell\"\naction_id = \"focus:shell\"\n", + ) + .unwrap(); + std::fs::write(dir.join("03-invalida.toml"), "label = \"X\"\n").unwrap(); + let entries = load_entries_from_dir(&dir); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].label, "Pluma"); + assert_eq!(entries[0].exec.as_deref(), Some("pluma-app")); + assert_eq!(entries[1].action_id, "focus:shell"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-matilda/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-matilda/Cargo.toml new file mode 100644 index 0000000..c8e3e4f --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-matilda/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "shuma-module-matilda" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-module-matilda — administración declarativa de servidores como módulo enchufable de shuma. Visualiza inventario, plan y log de aplicación; corre discover/plan/dry-run local. Apply remoto via matilda-linker llega en el bloque de conectividad." + +[dependencies] +shuma-module = { path = "../shuma-module" } +matilda-core = { path = "../../baremetal/matilda-core" } +matilda-plan = { path = "../../baremetal/matilda-plan" } +matilda-apply = { path = "../../baremetal/matilda-apply" } +matilda-discover = { path = "../../baremetal/matilda-discover" } +matilda-ghost = { path = "../../baremetal/matilda-ghost" } +matilda-linker = { path = "../../baremetal/matilda-linker" } +ssh = { workspace = true } +tokio = { workspace = true } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-splitter = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-matilda/LEEME.md b/02_ruway/shuma/sandbox/shuma-module-matilda/LEEME.md new file mode 100644 index 0000000..6a29ddd --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-matilda/LEEME.md @@ -0,0 +1,9 @@ +# shuma-module-matilda + +> Módulo matilda integrado de [shuma](../../README.md). + +Acceso rápido a [`matilda`](../../baremetal/matilda-app/README.md) desde el shell: discover, plan, apply, link — sin salir del chasis Llimphi. + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`matilda-app`](../../baremetal/matilda-app/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-matilda/README.md b/02_ruway/shuma/sandbox/shuma-module-matilda/README.md new file mode 100644 index 0000000..8d89926 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-matilda/README.md @@ -0,0 +1,9 @@ +# shuma-module-matilda + +> Integrated matilda module of [shuma](../../README.md). + +Quick access to [`matilda`](../../baremetal/matilda-app/README.md) from the shell: discover, plan, apply, link — without leaving the Llimphi chassis. + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`matilda-app`](../../baremetal/matilda-app/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs new file mode 100644 index 0000000..3db35fb --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-matilda/src/lib.rs @@ -0,0 +1,1128 @@ +//! `shuma-module-matilda` — administración declarativa como módulo. +//! +//! Adapta el CLI `matilda` para que viva como tab dentro de `shuma-shell-llimphi`: +//! visualiza el inventario, calcula el plan de reconciliación contra el +//! estado actual y previsualiza los pasos en seco (`dry_run`). Apply +//! real local también; apply remoto vía `matilda-linker` llega cuando +//! el chasis cablee `Source::Remote` (bloque de conectividad). +//! +//! Diseño del tab: +//! +//! ```text +//! Matilda · local · 1 host · 2 containers · 1 vhost +//! ┌──────────────────────────┬──────────────────────────────┐ +//! │ Inventario │ Plan (4 acciones) │ +//! │ │ 1. crear contenedor «web» │ +//! │ HOSTS (1) │ 2. crear contenedor «api» │ +//! │ edge-1 10.0.0.1 │ 3. crear vhost «sitio.com» │ +//! │ │ … │ +//! │ CONTAINERS (2) │ │ +//! │ web nginx:1.27 │ Log │ +//! │ api ejemplo/api │ $ docker pull nginx:1.27 │ +//! │ │ … │ +//! │ VHOSTS (1) │ │ +//! │ sitio.com → web:80 │ │ +//! └──────────────────────────┴──────────────────────────────┘ +//! ``` +//! +//! Contribuciones declarativas: +//! +//! - **Monitor "matilda · pasos"**: count del plan vigente (0 cuando el +//! inventario actual coincide con el deseado). +//! - **Shortcuts**: `Discover`, `Plan`, `Dry-run`. El chasis los pinta +//! en la toolbar de la app-header. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, FlexDirection, Size, Style}, + AlignItems, Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{DragPhase, View}; +use llimphi_theme::Theme; +use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette}; +use matilda_apply::plan_to_steps; +use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost}; +use matilda_discover::{discover_inventory, observed_inventory, ServerState}; +use matilda_ghost::{apply, dry_run, ApplyReport}; +use matilda_linker::{Linker, SshAuth, SshConfig}; +use matilda_plan::{plan, Op, Plan}; +use shuma_module::{ModuleContributions, MonitorSpec, Rgb, Sample, ShortcutSpec, Source}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +pub const ID: &str = "matilda"; + +/// Estado del módulo. El `desired` se llena con un ejemplo arrancable +/// hasta que el bloque 5 cablee `--inventory` desde el shumarc. El +/// `pending_steps` se comparte por `Arc>` para que el sampler +/// del monitor lo lea desde el thread de polling sin pelear con el UI. +#[derive(Debug, Clone)] +pub struct State { + pub source: Source, + pub desired: Inventory, + pub current: Option, + pub plan: Option, + pub log: Vec, + pub split_width: f32, + /// Path al inventario JSON, si vino del shumarc. El módulo lo + /// expone para que el chasis sepa de dónde recargar al pulsar + /// «Reload»; el módulo mismo no hace IO, sólo recibe `SetDesired`. + pub inventory_path: Option, + pending_steps: Arc>, +} + +impl State { + pub fn new(source: Source) -> Self { + Self::with_inventory(source, example_inventory()) + } + + /// Variante de `new` con inventario explícito — usada por el chasis + /// cuando el `shumarc` declara `inventory = "path/to/inv.json"`. + pub fn with_inventory(source: Source, desired: Inventory) -> Self { + Self { + source, + desired, + current: None, + plan: None, + log: Vec::new(), + split_width: 380.0, + inventory_path: None, + pending_steps: Arc::new(Mutex::new(0)), + } + } + + /// Como `with_inventory`, recordando además el path para reloads. + pub fn with_inventory_path(source: Source, desired: Inventory, path: PathBuf) -> Self { + let mut s = Self::with_inventory(source, desired); + s.inventory_path = Some(path); + s + } + + /// Inventario actual contra el cual reconciliar — si no se ha + /// hecho discover, asume "vacío" (todo es creación). Equivale al + /// modo CLI `matilda plan inv.json` sin `--discover`. + pub fn current_or_empty(&self) -> Inventory { + self.current.clone().unwrap_or_default() + } + + /// Cuenta de pasos pendientes — alimenta el monitor. + pub fn pending_count(&self) -> usize { + self.plan.as_ref().map(|p| p.len()).unwrap_or(0) + } +} + +#[derive(Debug, Clone)] +pub enum Msg { + /// Descubre el inventario actual del servidor (local; los Remote + /// los maneja el chasis vía `discover_remote_blocking` y reenvía + /// el resultado como `SetCurrent`). + Discover, + /// Recalcula el plan deseado-vs-actual. + MakePlan, + /// Ejecuta `dry_run` sobre los pasos del plan y vuelca al log. + DryRun, + /// Aplica el plan al servidor (local sincrónico; remoto delega al + /// chasis, que spawnea el thread SSH y devuelve `ApplyReport`). + Apply, + /// Setter directo del inventario actual — usado para inyectar el + /// resultado del discover remoto desde el chasis (cuando el SSH + /// terminó en un thread aparte). + SetCurrent(Inventory), + /// Línea informativa para el log — útil para que el chasis avise + /// "conectando", "fallo de SSH", etc., sin acoplarse al módulo. + LogLine(String), + /// Inyecta el reporte de un dry-run remoto que el chasis corrió en + /// un thread aparte (cada `String` es una línea del log). + DryRunReport(Vec), + /// Inyecta el reporte de un apply remoto: líneas para el log y, si + /// la aplicación fue completa, el nuevo inventario actual (re- + /// descubierto post-apply) para resetear plan + pendientes. + ApplyReport { + lines: Vec, + new_current: Option, + }, + /// Reemplaza el inventario deseado — usado por el chasis tras un + /// reload exitoso desde disco. Invalida el plan vigente. + SetDesired(Inventory), + /// Drag del splitter inventario|plan. + ResizeSplit(f32), +} + +/// Mapea el `action_id` de un `ShortcutAction::ModuleAction` al `Msg` +/// que corresponde. Retorna `None` si el action_id no pertenece a este +/// módulo — el chasis simplemente lo ignora. +pub fn dispatch(action_id: &str) -> Option { + match action_id { + "matilda.discover" => Some(Msg::Discover), + "matilda.plan" => Some(Msg::MakePlan), + "matilda.dry_run" => Some(Msg::DryRun), + "matilda.apply" => Some(Msg::Apply), + _ => None, + } +} + +pub fn update(state: State, msg: Msg) -> State { + let mut s = state; + match msg { + Msg::Discover => match &s.source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { + // Matilda no habla todavía con el daemon de shuma — corre + // siempre sobre el FS local cuando no es SSH. + let current = discover_inventory(&s.desired); + s.log.push(format!( + "✔ discover local: {} containers, {} vhosts", + current.containers().count(), + current.vhosts().count() + )); + s.current = Some(current); + } + Source::Remote { host, .. } => { + // El discover remoto necesita un runtime tokio y vive + // en un thread del chasis (ver `discover_remote_blocking`). + // Aquí sólo registramos que el módulo no puede hacerlo + // por sí mismo desde el hilo de UI — es informativo. + s.log.push(format!( + "→ discover remoto en {host} delegado al chasis" + )); + } + }, + Msg::MakePlan => { + let p = plan(&s.current_or_empty(), &s.desired); + s.log.push(format!( + "✔ plan: {} acciones ({} crear, {} actualizar, {} eliminar)", + p.len(), + p.count(Op::Create), + p.count(Op::Update), + p.count(Op::Remove) + )); + *s.pending_steps.lock().unwrap() = p.len(); + s.plan = Some(p); + } + Msg::DryRun => { + // El dry-run para Source::Remote vive en el chasis (necesita + // SSH + thread); aquí sólo manejamos Local sincrónicamente. + // Para Remote, el chasis interceptó el shortcut y dispatchó + // el resultado vía `Msg::DryRunReport`. + if s.source.is_remote() { + s.log + .push("→ dry-run remoto delegado al chasis".into()); + } else { + let p = match &s.plan { + Some(p) => p.clone(), + None => plan(&s.current_or_empty(), &s.desired), + }; + let steps = plan_to_steps(&p, &s.desired); + if steps.is_empty() { + s.log.push("Sin pasos: nada que aplicar.".into()); + } else { + s.log.push(format!("— dry-run de {} pasos —", steps.len())); + let report: ApplyReport = dry_run(&steps); + for r in &report.results { + s.log.push(format!( + "{} {}", + if r.ok { "✔" } else { "✘" }, + r.describe + )); + for line in &r.log { + s.log.push(format!(" {line}")); + } + } + } + } + cap_log(&mut s.log); + } + Msg::Apply => { + if s.source.is_remote() { + s.log + .push("→ apply remoto delegado al chasis".into()); + } else { + let p = match &s.plan { + Some(p) => p.clone(), + None => plan(&s.current_or_empty(), &s.desired), + }; + let steps = plan_to_steps(&p, &s.desired); + if steps.is_empty() { + s.log.push("Sin pasos: nada que aplicar.".into()); + } else { + s.log.push(format!("— aplicando {} pasos —", steps.len())); + let report: ApplyReport = apply(&steps); + for r in &report.results { + s.log.push(format!( + "{} {}", + if r.ok { "✔" } else { "✘" }, + r.describe + )); + for line in &r.log { + s.log.push(format!(" {line}")); + } + } + s.log.push(format!( + "{} de {} pasos aplicados.", + report.applied(), + report.results.len() + )); + if report.all_ok() { + // Re-discover local para resetear plan + pendientes. + let current = discover_inventory(&s.desired); + let new_plan = plan(¤t, &s.desired); + *s.pending_steps.lock().unwrap() = new_plan.len(); + s.current = Some(current); + s.plan = Some(new_plan); + } else { + s.log.push("✘ se detuvo en el primer error.".into()); + } + } + } + cap_log(&mut s.log); + } + Msg::DryRunReport(lines) => { + for line in lines { + s.log.push(line); + } + cap_log(&mut s.log); + } + Msg::ApplyReport { lines, new_current } => { + for line in lines { + s.log.push(line); + } + if let Some(inv) = new_current { + let new_plan = plan(&inv, &s.desired); + *s.pending_steps.lock().unwrap() = new_plan.len(); + s.current = Some(inv); + s.plan = Some(new_plan); + } + cap_log(&mut s.log); + } + Msg::SetCurrent(inv) => { + s.log.push(format!( + "✔ current: {} containers, {} vhosts", + inv.containers().count(), + inv.vhosts().count() + )); + s.current = Some(inv); + } + Msg::LogLine(line) => { + s.log.push(line); + cap_log(&mut s.log); + } + Msg::SetDesired(inv) => { + s.log.push(format!( + "✔ inventario recargado: {} hosts, {} containers, {} vhosts", + inv.hosts().count(), + inv.containers().count(), + inv.vhosts().count() + )); + s.desired = inv; + s.plan = None; + *s.pending_steps.lock().unwrap() = 0; + } + Msg::ResizeSplit(dx) => { + s.split_width = (s.split_width + dx).clamp(220.0, 720.0); + } + } + s +} + +fn cap_log(log: &mut Vec) { + const MAX: usize = 200; + let len = log.len(); + if len > MAX { + log.drain(0..len - MAX); + } +} + +// ─── Discover y dry-run remotos ───────────────────────────────────── + +/// Ruta default de la clave SSH del usuario; coincide con el matilda CLI. +fn default_ssh_key() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + PathBuf::from(format!("{home}/.ssh/id_ed25519")) +} + +/// Descubre el inventario actual del servidor remoto. **Bloqueante**: +/// crea un runtime tokio efímero, conecta por SSH y corre +/// `docker ps -a --format '{{.Names}}'` + `ls /etc/nginx/sites-enabled`. +/// Pensado para que el chasis lo invoque dentro de `Handle::spawn` +/// (un thread aparte) — no llamar desde el hilo de UI. +/// +/// Para Source::Local fallback a `discover_inventory` (no necesita +/// SSH, pero usa el mismo entrypoint para uniformidad). +pub fn discover_remote_blocking(source: &Source, desired: &Inventory) -> Result { + match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { + Ok(discover_inventory(desired)) + } + Source::Remote { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + fetch_remote_inventory(&linker, desired).await + }) + } + } +} + +/// Equivalente remoto de `Msg::DryRun`: conecta por SSH, descubre el +/// inventory actual, calcula el plan deseado-vs-actual y enumera los +/// pasos que SE EJECUTARÍAN — sin invocar ninguno. Útil para validar +/// que el `Source::Remote` está bien configurado y previsualizar el +/// cambio antes de un eventual Apply real (fuera de scope aquí). +/// +/// Devuelve un `Vec` con líneas listas para insertar al log +/// (incluyendo el reporte de dry-run de cada paso). El chasis las +/// envuelve en `Msg::DryRunReport`. +pub fn dry_run_remote_blocking( + source: &Source, + desired: &Inventory, +) -> Result, String> { + let mut lines = Vec::new(); + + let current = match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { + discover_inventory(desired) + } + Source::Remote { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + fetch_remote_inventory(&linker, desired).await + })? + } + }; + lines.push(format!( + "✔ current: {} containers, {} vhosts", + current.containers().count(), + current.vhosts().count() + )); + + let p = plan(¤t, desired); + if p.is_empty() { + lines.push("Sin cambios: el servidor ya está al día.".into()); + return Ok(lines); + } + lines.push(format!( + "plan: {} acciones ({} crear, {} actualizar, {} eliminar)", + p.len(), + p.count(Op::Create), + p.count(Op::Update), + p.count(Op::Remove) + )); + + let steps = plan_to_steps(&p, desired); + let report: ApplyReport = dry_run(&steps); + for r in &report.results { + lines.push(format!( + "{} {}", + if r.ok { "✔" } else { "✘" }, + r.describe + )); + for line in &r.log { + lines.push(format!(" {line}")); + } + } + Ok(lines) +} + +/// Aplica el plan deseado-vs-actual en el servidor remoto: conecta por +/// SSH, descubre el inventario, calcula el plan, ejecuta los pasos en +/// orden y re-descubre el estado final. **Bloqueante** — pensado para +/// que el chasis lo invoque dentro de `Handle::spawn` y reenvíe el +/// resultado por `Msg::ApplyReport`. +/// +/// Devuelve `(lines, new_current)`: el log textual y, si todos los +/// pasos completaron, el inventario re-observado (para resetear el +/// plan/pendientes del módulo). Si algún paso falla, `new_current` es +/// `None` — la UI conserva el plan vigente para que el operador vea +/// dónde se rompió. +pub fn apply_remote_blocking( + source: &Source, + desired: &Inventory, +) -> Result<(Vec, Option), String> { + let mut lines = Vec::new(); + + match source { + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { + // Local lo maneja `Msg::Apply` sincrónicamente. Para + // uniformidad damos un fallback síncrono sin tocar el UI. + let current = discover_inventory(desired); + let p = plan(¤t, desired); + if p.is_empty() { + lines.push("Sin cambios: nada que aplicar.".into()); + return Ok((lines, Some(current))); + } + let steps = plan_to_steps(&p, desired); + let report: ApplyReport = apply(&steps); + push_apply_log(&mut lines, &report); + let new_current = if report.all_ok() { + Some(discover_inventory(desired)) + } else { + None + }; + Ok((lines, new_current)) + } + Source::Remote { .. } => { + let config = ssh_config_for(source)?; + let rt = blocking_runtime()?; + rt.block_on(async move { + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("ssh connect: {e}"))?; + let current = fetch_remote_inventory(&linker, desired).await?; + lines.push(format!( + "✔ current: {} containers, {} vhosts", + current.containers().count(), + current.vhosts().count() + )); + let p = plan(¤t, desired); + if p.is_empty() { + lines.push("Sin cambios: el servidor ya está al día.".into()); + return Ok((lines, Some(current))); + } + lines.push(format!( + "plan: {} acciones ({} crear, {} actualizar, {} eliminar)", + p.len(), + p.count(Op::Create), + p.count(Op::Update), + p.count(Op::Remove) + )); + let steps = plan_to_steps(&p, desired); + lines.push(format!("— aplicando {} pasos por SSH —", steps.len())); + let report = linker.apply(&steps).await; + push_apply_log(&mut lines, &report); + let new_current = if report.all_ok() { + Some(fetch_remote_inventory(&linker, desired).await?) + } else { + None + }; + Ok((lines, new_current)) + }) + } + } +} + +fn push_apply_log(lines: &mut Vec, report: &ApplyReport) { + for r in &report.results { + lines.push(format!( + "{} {}", + if r.ok { "✔" } else { "✘" }, + r.describe + )); + for line in &r.log { + lines.push(format!(" {line}")); + } + } + lines.push(format!( + "{} de {} pasos aplicados.", + report.applied(), + report.results.len() + )); + if !report.all_ok() { + lines.push("✘ se detuvo en el primer error.".into()); + } +} + +fn ssh_config_for(source: &Source) -> Result { + match source { + Source::Remote { host, user, port, .. } => { + let auth = SshAuth::Key { + path: default_ssh_key(), + passphrase: None, + }; + let mut config = SshConfig::new(host.as_str(), user.as_str(), auth); + config.port = *port; + Ok(config) + } + Source::Local | Source::Daemon { .. } | Source::DaemonTcp { .. } => { + Err("ssh_config_for esperaba Source::Remote".into()) + } + } +} + +fn blocking_runtime() -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("tokio runtime: {e}")) +} + +async fn fetch_remote_inventory( + linker: &Linker, + desired: &Inventory, +) -> Result { + let containers_text = linker + .exec("docker ps -a --format '{{.Names}}' 2>/dev/null || true") + .await + .map_err(|e| format!("docker ps: {e}"))?; + let vhosts_text = linker + .exec("ls -1 /etc/nginx/sites-enabled 2>/dev/null || true") + .await + .map_err(|e| format!("ls sites-enabled: {e}"))?; + let state = ServerState { + containers: matilda_discover::parse_docker_names(&containers_text), + vhosts: matilda_discover::parse_nginx_sites(&vhosts_text), + }; + Ok(observed_inventory(&state, desired)) +} + +/// Inventario de ejemplo — equivale al `matilda example`. Permite +/// arrancar el módulo sin un archivo de inventario y demostrar el +/// flujo plan/dry-run sin tocar nada del servidor. +pub 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 +} + +// ─── view ────────────────────────────────────────────────────────── + +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + let header = matilda_header(state, theme); + + let inv_pane = inventory_pane(state, theme); + let plan_pane = plan_and_log_pane(state, theme); + + let splitter_palette = SplitterPalette::from_theme(theme); + let lift_resize = lift.clone(); + let body = splitter_two( + Direction::Row, + inv_pane, + PaneSize::Fixed(state.split_width), + plan_pane, + PaneSize::Flex, + move |phase, dx| match phase { + DragPhase::Move => Some(lift_resize(Msg::ResizeSplit(dx))), + DragPhase::End => None, + }, + &splitter_palette, + ); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![header, body]) +} + +fn matilda_header(state: &State, theme: &Theme) -> View { + let label = format!( + "Matilda · {} · {} hosts · {} containers · {} vhosts", + state.source.label(), + state.desired.hosts().count(), + state.desired.containers().count(), + state.desired.vhosts().count(), + ); + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(28.0_f32), + }, + padding: Rect { + left: length(14.0_f32), + right: length(14.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel) + .text_aligned(label, 12.0, theme.fg_text, Alignment::Start) +} + +/// Panel izquierdo: el inventario deseado en 3 secciones (hosts / +/// containers / vhosts). Compuesto como Views planos — el +/// `llimphi-widget-list` exigiría un `on_click` por fila, y en este +/// tab las filas son informativas (no se seleccionan todavía). +fn inventory_pane(state: &State, theme: &Theme) -> View { + let mut children: Vec> = Vec::new(); + + children.push(section_label( + &format!("HOSTS ({})", state.desired.hosts().count()), + theme, + )); + for h in state.desired.hosts() { + children.push(inv_row(&format!(" {} {}", h.name, h.address), theme)); + } + + children.push(section_label( + &format!("CONTAINERS ({})", state.desired.containers().count()), + theme, + )); + for c in state.desired.containers() { + children.push(inv_row(&format!(" {} {}", c.name, c.image), theme)); + } + + children.push(section_label( + &format!("VHOSTS ({})", state.desired.vhosts().count()), + theme, + )); + for v in state.desired.vhosts() { + children.push(inv_row( + &format!(" {} → {}", v.domain, describe_upstream(&v.upstream)), + theme, + )); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(2.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .children(children) +} + +fn describe_upstream(u: &matilda_core::Upstream) -> String { + use matilda_core::Upstream::*; + match u { + Container { name, port } => format!("{name}:{port}"), + Address(addr) => addr.clone(), + } +} + +fn inv_row(text: &str, theme: &Theme) -> View { + text_row(text, theme.fg_text, theme) +} + +fn plan_and_log_pane(state: &State, theme: &Theme) -> View { + let plan_label = match &state.plan { + Some(p) if p.is_empty() => "Plan · sin cambios".to_string(), + Some(p) => format!("Plan · {} acciones", p.len()), + None => "Plan · sin calcular (pulsá «Plan» en la toolbar)".to_string(), + }; + + let plan_header = section_label(&plan_label, theme); + + let mut plan_children: Vec> = vec![plan_header]; + if let Some(p) = &state.plan { + for (i, action) in p.actions.iter().enumerate() { + plan_children.push(text_row( + &format!("{:>2}. {}", i + 1, action.describe()), + theme.fg_text, + theme, + )); + } + } + + plan_children.push(section_label("Log", theme)); + for line in state.log.iter().rev().take(40).rev() { + plan_children.push(text_row(line, theme.fg_muted, theme)); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(2.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(plan_children) +} + +fn section_label(text: &str, theme: &Theme) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + margin: Rect { + left: length(0.0_f32), + right: length(0.0_f32), + top: length(6.0_f32), + bottom: length(2.0_f32), + }, + ..Default::default() + }) + .text_aligned(text.to_string(), 11.0, theme.accent, Alignment::Start) +} + +fn text_row( + text: &str, + color: llimphi_ui::llimphi_raster::peniko::Color, + _theme: &Theme, +) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }) + .text_aligned(text.to_string(), 11.0, color, Alignment::Start) +} + +// ─── contributions ────────────────────────────────────────────────── + +pub fn contributions(state: &State) -> ModuleContributions { + let pending = state.pending_steps.clone(); + let monitor = MonitorSpec { + id: "matilda.pending", + label: format!("matilda · {}", state.source.label()), + accent: Rgb::new(0xE5, 0xC0, 0x7B), + history_capacity: 60, + period_secs: 5.0, + sampler: Box::new(move || { + let n = *pending.lock().unwrap(); + Sample::new(n as f32, format!("{n} pendientes")) + }), + }; + + ModuleContributions { + monitors: vec![monitor], + shortcuts: vec![ + ShortcutSpec::module_action("Discover", "matilda.discover") + .with_hint("Lee el estado actual del servidor"), + ShortcutSpec::module_action("Plan", "matilda.plan") + .with_hint("Calcula la reconciliación deseado-vs-actual"), + ShortcutSpec::module_action("Dry-run", "matilda.dry_run") + .with_hint("Previsualiza los pasos sin aplicar"), + ShortcutSpec::module_action("Apply", "matilda.apply") + .with_hint("Reconcilia el servidor con el inventario deseado"), + ShortcutSpec::module_action("Reload", "matilda.reload") + .with_hint("Relee el inventario JSON desde disco"), + ], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_is_stable() { + assert_eq!(ID, "matilda"); + } + + #[test] + fn example_inventory_has_expected_shape() { + let inv = example_inventory(); + assert_eq!(inv.hosts().count(), 1); + assert_eq!(inv.containers().count(), 2); + assert_eq!(inv.vhosts().count(), 1); + } + + #[test] + fn with_inventory_uses_provided_desired() { + let mut inv = matilda_core::Inventory::new(); + inv.add_container(matilda_core::Container::new("only", "alpine:3")); + let s = State::with_inventory(Source::Local, inv); + assert_eq!(s.desired.containers().count(), 1); + assert_eq!(s.desired.hosts().count(), 0); + assert!(s.plan.is_none()); + } + + #[test] + fn fresh_state_has_no_plan_no_current() { + let s = State::new(Source::Local); + assert!(s.plan.is_none()); + assert!(s.current.is_none()); + assert_eq!(s.pending_count(), 0); + } + + #[test] + fn make_plan_against_empty_current_creates_all() { + let s = State::new(Source::Local); + let s = update(s, Msg::MakePlan); + let plan = s.plan.as_ref().expect("plan se debe haber calculado"); + // 2 containers + 1 vhost (los hosts no producen acción si no hay + // current, pero el example_inventory tiene 1 → cuenta como create). + assert_eq!(plan.count(Op::Create), 4); + assert_eq!(s.pending_count(), 4); + } + + #[test] + fn dry_run_appends_log_lines() { + let mut s = State::new(Source::Local); + s = update(s, Msg::MakePlan); + let log_before = s.log.len(); + s = update(s, Msg::DryRun); + assert!(s.log.len() > log_before, "dry-run debe agregar líneas al log"); + } + + #[test] + fn dry_run_with_empty_plan_says_nothing_to_apply() { + let mut s = State::new(Source::Local); + // Force plan vacío: igualamos current al desired. + s.current = Some(s.desired.clone()); + s = update(s, Msg::MakePlan); + assert_eq!(s.plan.as_ref().unwrap().len(), 0); + s = update(s, Msg::DryRun); + assert!(s + .log + .iter() + .any(|l| l.contains("nada que aplicar"))); + } + + #[test] + fn remote_discover_is_delegated_to_the_chassis() { + // El módulo no abre SSH desde el update — el chasis es quien + // spawnea el thread con `discover_remote_blocking`. Aquí + // verificamos sólo el log informativo. + let s = State::new(Source::Remote { + host: "srv".into(), + user: "ops".into(), + port: 22, + label: None, + }); + let s = update(s, Msg::Discover); + assert!(s.log.iter().any(|l| l.contains("delegado al chasis"))); + assert!(s.current.is_none()); + } + + #[test] + fn set_current_updates_state_and_logs() { + let mut s = State::new(Source::Local); + let mut inv = matilda_core::Inventory::new(); + inv.add_container(matilda_core::Container::new("web", "nginx")); + s = update(s, Msg::SetCurrent(inv)); + assert!(s.current.is_some()); + assert_eq!(s.current.as_ref().unwrap().containers().count(), 1); + assert!(s.log.iter().any(|l| l.contains("1 containers"))); + } + + #[test] + fn log_line_appends_and_caps_at_200() { + let mut s = State::new(Source::Local); + for i in 0..250 { + s = update(s, Msg::LogLine(format!("line {i}"))); + } + assert_eq!(s.log.len(), 200); + // Las primeras 50 líneas deben haberse descartado. + assert!(s.log[0].contains("line 50")); + } + + #[test] + fn discover_remote_blocking_local_falls_back_to_local() { + // Para `Source::Local` no abre SSH — `discover_inventory` corre + // localmente. En CI sin docker, retorna inventory vacío sin error. + let inv = matilda_core::Inventory::new(); + let res = discover_remote_blocking(&Source::Local, &inv); + assert!(res.is_ok()); + } + + #[test] + fn dry_run_report_appends_lines_to_log() { + let mut s = State::new(Source::Local); + let lines = vec!["línea 1".into(), "línea 2".into(), "línea 3".into()]; + s = update(s, Msg::DryRunReport(lines)); + assert!(s.log.iter().any(|l| l == "línea 1")); + assert!(s.log.iter().any(|l| l == "línea 3")); + } + + #[test] + fn dry_run_with_remote_source_defers_to_chassis() { + let s = State::new(Source::Remote { + host: "srv".into(), + user: "ops".into(), + port: 22, + label: None, + }); + let s = update(s, Msg::DryRun); + assert!(s + .log + .iter() + .any(|l| l.contains("delegado al chasis"))); + } + + #[test] + fn dry_run_remote_blocking_local_returns_lines() { + let inv = matilda_core::Inventory::new(); + let res = dry_run_remote_blocking(&Source::Local, &inv); + assert!(res.is_ok()); + let lines = res.unwrap(); + assert!(!lines.is_empty()); + // El primer reporte siempre incluye el current. + assert!(lines[0].contains("current")); + } + + #[test] + fn resize_split_clamps_to_range() { + let s = State::new(Source::Local); + let s = update(s, Msg::ResizeSplit(-10000.0)); + assert!(s.split_width >= 220.0); + let s = update(s, Msg::ResizeSplit(10000.0)); + assert!(s.split_width <= 720.0); + } + + #[test] + fn dispatch_maps_action_ids() { + assert!(matches!(dispatch("matilda.discover"), Some(Msg::Discover))); + assert!(matches!(dispatch("matilda.plan"), Some(Msg::MakePlan))); + assert!(matches!(dispatch("matilda.dry_run"), Some(Msg::DryRun))); + assert!(matches!(dispatch("matilda.apply"), Some(Msg::Apply))); + assert!(dispatch("desconocido").is_none()); + } + + #[test] + fn contributions_expose_monitor_and_five_shortcuts() { + let s = State::new(Source::Local); + let c = contributions(&s); + assert_eq!(c.monitors.len(), 1); + assert_eq!(c.shortcuts.len(), 5); + assert_eq!(c.shortcuts[0].label, "Discover"); + assert_eq!(c.shortcuts[1].label, "Plan"); + assert_eq!(c.shortcuts[2].label, "Dry-run"); + assert_eq!(c.shortcuts[3].label, "Apply"); + assert_eq!(c.shortcuts[4].label, "Reload"); + } + + #[test] + fn with_inventory_path_records_the_path() { + let inv = matilda_core::Inventory::new(); + let p = PathBuf::from("/etc/matilda/inv.json"); + let s = State::with_inventory_path(Source::Local, inv, p.clone()); + assert_eq!(s.inventory_path.as_deref(), Some(p.as_path())); + } + + #[test] + fn set_desired_replaces_inventory_and_invalidates_plan() { + let mut s = State::new(Source::Local); + s = update(s, Msg::MakePlan); + assert!(s.pending_count() > 0); + + let mut new_inv = matilda_core::Inventory::new(); + new_inv.add_container(matilda_core::Container::new("alone", "alpine")); + s = update(s, Msg::SetDesired(new_inv)); + + assert_eq!(s.desired.containers().count(), 1); + assert_eq!(s.desired.hosts().count(), 0); + assert!(s.plan.is_none()); + assert_eq!(s.pending_count(), 0); + assert!(s.log.iter().any(|l| l.contains("recargado"))); + } + + #[test] + fn apply_with_remote_source_defers_to_chassis() { + let s = State::new(Source::Remote { + host: "srv".into(), + user: "ops".into(), + port: 22, + label: None, + }); + let s = update(s, Msg::Apply); + assert!(s + .log + .iter() + .any(|l| l.contains("delegado al chasis"))); + assert!(s.plan.is_none()); + } + + #[test] + fn apply_report_with_new_current_resets_plan() { + let mut s = State::new(Source::Local); + // Forzamos un plan vigente con pendientes. + s = update(s, Msg::MakePlan); + assert!(s.pending_count() > 0); + + // Ahora simulamos que el chasis aplicó remoto y re-descubrió: + // el nuevo current coincide con el desired → plan vacío. + let new_current = s.desired.clone(); + s = update( + s, + Msg::ApplyReport { + lines: vec!["✔ aplicado".into()], + new_current: Some(new_current), + }, + ); + assert_eq!(s.pending_count(), 0); + assert_eq!(s.plan.as_ref().unwrap().len(), 0); + assert!(s.log.iter().any(|l| l == "✔ aplicado")); + } + + #[test] + fn apply_report_without_new_current_keeps_plan() { + let mut s = State::new(Source::Local); + s = update(s, Msg::MakePlan); + let pending_before = s.pending_count(); + s = update( + s, + Msg::ApplyReport { + lines: vec!["✘ falló paso 2".into()], + new_current: None, + }, + ); + // El plan vigente sobrevive — el operador inspecciona dónde se rompió. + assert_eq!(s.pending_count(), pending_before); + assert!(s.log.iter().any(|l| l.contains("falló paso 2"))); + } + + #[test] + fn apply_remote_blocking_local_returns_lines() { + let inv = matilda_core::Inventory::new(); + let res = apply_remote_blocking(&Source::Local, &inv); + assert!(res.is_ok()); + let (lines, new_current) = res.unwrap(); + // Inventory vacío → "nada que aplicar"; new_current refleja el local. + assert!(!lines.is_empty()); + assert!(new_current.is_some()); + } + + #[test] + fn monitor_sampler_reflects_pending_steps() { + let mut s = State::new(Source::Local); + s = update(s, Msg::MakePlan); // 4 pendientes + let c = contributions(&s); + let sample = (c.monitors[0].sampler)(); + assert_eq!(sample.value, 4.0); + assert_eq!(sample.display, "4 pendientes"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-minga/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-minga/Cargo.toml new file mode 100644 index 0000000..a2d5f5b --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-minga/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "shuma-module-minga" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-module-minga — tab del shell que muestra el estado del repo Minga del cwd: counts, raíces recientes con su α-hash y dialect, y un monitor de pasos pendientes (atestaciones no firmadas locales)." + +[dependencies] +shuma-module = { path = "../shuma-module" } +minga-core = { workspace = true } +minga-store = { workspace = true } +minga-vfs = { workspace = true } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-minga/LEEME.md b/02_ruway/shuma/sandbox/shuma-module-minga/LEEME.md new file mode 100644 index 0000000..a043825 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-minga/LEEME.md @@ -0,0 +1,9 @@ +# shuma-module-minga + +> Módulo minga integrado de [shuma](../../README.md). + +Acceso a la red de pares [`minga`](../../../../03_ukupacha/minga/README.md) desde el shell: ver peers, transferir, ejecutar remoto via gateway. Conviven en el mismo chasis. + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`minga-cli`](../../../../03_ukupacha/minga/minga-cli/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-minga/README.md b/02_ruway/shuma/sandbox/shuma-module-minga/README.md new file mode 100644 index 0000000..98666fb --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-minga/README.md @@ -0,0 +1,9 @@ +# shuma-module-minga + +> Integrated minga module of [shuma](../../README.md). + +Access to the [`minga`](../../../../03_ukupacha/minga/README.md) peer network from the shell: view peers, transfer, gateway-mediated remote exec. Coexists in the same chassis. + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`minga-cli`](../../../../03_ukupacha/minga/minga-cli/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs new file mode 100644 index 0000000..cf76084 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-minga/src/lib.rs @@ -0,0 +1,720 @@ +//! `shuma-module-minga` — visualizador del repo Minga del cwd como tab +//! del shell. +//! +//! Muestra: +//! - Counts del repo: raíces (α-hashes), nodos del grafo CAS, +//! atestaciones, claves del MST. +//! - Lista de las últimas raíces ingeridas con su α-hash y dialect. +//! +//! Diseño del tab: +//! +//! ```text +//! Minga · local · /home/u/proyecto/.minga +//! raíces: 14 · nodos: 1322 · atestaciones: 14 · mst: 14 +//! ──────────────────────────────────────────────────── +//! a1b2c3d4e5f6789a rust +//! f5e6a7b80c1d2e3f python +//! … +//! ``` +//! +//! El módulo abre el `PersistentRepo` en read-only cada refresh. Si la +//! apertura falla (no hay `.minga` en el cwd, sled corrupto, etc.) el +//! tab muestra un mensaje informativo en lugar de los counts. +//! +//! Contribuciones: +//! - Monitor "minga · raíces": curva con la cantidad de raíces del MST. +//! - Shortcut "Refresh": fuerza un re-load del snapshot. + +#![forbid(unsafe_code)] + +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, AlignItems, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::View; +use llimphi_theme::Theme; +use minga_core::ContentHash; +use minga_store::PersistentRepo; +use shuma_module::{ModuleContributions, MonitorSpec, Rgb, Sample, ShortcutSpec, Source}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +/// `id` canónico — el chasis lo usa para enrutar el shumarc. +pub const ID: &str = "minga"; + +/// Subdirectorio del repo sled dentro del directorio Minga +/// (típicamente `/.minga/repo`). +const REPO_SUBDIR: &str = "repo"; + +/// Cuántas raíces recientes mostrar en el tab — paridad con el +/// `RECENT_LIMIT` del explorer standalone. +pub const RECENT_LIMIT: usize = 10; + +/// Snapshot del repo: counts + muestra de raíces. Inmutable una vez +/// construido por [`load_snapshot`]. +#[derive(Debug, Clone, Default)] +pub struct RepoSnapshot { + pub roots: usize, + pub nodes: usize, + pub attestations: usize, + pub mst_keys: usize, + /// Raíces recientes (orden lexicográfico de sled — no temporal). + pub recent: Vec, +} + +/// Una fila del listado de raíces, ya formateada para la vista. +#[derive(Debug, Clone)] +pub struct RootRow { + pub alpha: ContentHash, + pub dialect: Option<&'static str>, + /// Resultado del último `Verify` sobre esta raíz: `Some(true)` si + /// el α-hash es consistente con su contenido bajo algún dialect, + /// `Some(false)` si no, `None` si nunca se verificó. + pub verified: Option, + /// `true` si al menos un autor firmó una retracción sobre esta + /// raíz. La UI lo marca con un dot rojo a la izquierda del α-hash. + /// Cuántos autores retractaron — la lectura semántica es "la raíz + /// tiene desconfianza histórica", no "está borrada" (las + /// atestaciones originales se preservan como prueba). + pub retracted: bool, +} + +#[derive(Debug, Clone)] +pub struct State { + pub source: Source, + /// Path del directorio Minga (típicamente `/.minga`). El + /// módulo lee `/repo/` para abrir sled. + pub repo_path: PathBuf, + pub snapshot: Option, + pub error: Option, + /// Raíz seleccionada (último click en una fila). El chasis dispara + /// `load_root_source` y reenvía el resultado. + pub selected: Option, + /// Fuente reconstruida de la raíz seleccionada — o un mensaje de + /// error si la reconstrucción falló. `None` mientras carga. + pub selected_source: Option>, + /// Counter de raíces compartido con el `sampler` del monitor. + /// Mutex porque el sampler corre en un hilo del host. + roots_count: Arc>, +} + +impl State { + /// Estado por defecto: source local, repo_path = `/.minga`. + pub fn new(source: Source) -> Self { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + Self::with_repo_path(source, cwd.join(".minga")) + } + + /// Estado apuntando a un `repo_path` específico — para tests o + /// cuando el shumarc lo override en `options`. + pub fn with_repo_path(source: Source, repo_path: PathBuf) -> Self { + Self { + source, + repo_path, + snapshot: None, + error: None, + selected: None, + selected_source: None, + roots_count: Arc::new(Mutex::new(0)), + } + } + + /// Cuenta de raíces actual (alimenta el monitor de `contributions`). + pub fn roots_count(&self) -> usize { + *self.roots_count.lock().unwrap() + } +} + +/// Mensajes del módulo. El host enruta `Refresh` desde el shortcut +/// `minga.refresh`; `SnapshotReady` viene del worker que abrió sled. +#[derive(Debug, Clone)] +pub enum Msg { + /// Pide releer el repo. El chasis debe llamar a [`load_snapshot`] + /// en un thread aparte y reenviar el resultado como `SnapshotReady`. + Refresh, + /// Resultado de un refresh. + SnapshotReady(Result), + /// El usuario clickeó una raíz. El chasis carga el contenido en un + /// thread y reenvía como `SourceLoaded`. + SelectRoot(ContentHash), + /// Fuente reconstruida (o error) para la raíz que `selected` + /// señala. Se ignora si la `alpha` ya no coincide con `selected` + /// (otro click llegó antes que este resultado). + SourceLoaded { + alpha: ContentHash, + result: Result, + }, + /// Cierra el visor de fuente — deselecciona. + DeselectRoot, + /// Pide verificar todas las raíces visibles. El chasis spawnea el + /// trabajo y reenvía `VerifyAllReady`. + VerifyAll, + /// Resultado de un VerifyAll: para cada α, `true` si la raíz es + /// consistente bajo algún dialect, `false` si no. + VerifyAllReady(Vec<(ContentHash, bool)>), +} + +/// Mapea `action_id` de `ShortcutAction::ModuleAction` al `Msg`. +pub fn dispatch(action_id: &str) -> Option { + match action_id { + "minga.refresh" => Some(Msg::Refresh), + "minga.verify_all" => Some(Msg::VerifyAll), + _ => None, + } +} + +/// Aplica un `Msg` al estado. `Refresh` es **declarativo**: marca el +/// estado pero NO hace IO. El chasis lanza el load fuera de `update`. +pub fn update(state: State, msg: Msg) -> State { + let mut s = state; + match msg { + Msg::Refresh => { + s.error = None; + } + Msg::SnapshotReady(Ok(snap)) => { + *s.roots_count.lock().unwrap() = snap.roots; + s.snapshot = Some(snap); + s.error = None; + } + Msg::SnapshotReady(Err(e)) => { + s.error = Some(e); + } + Msg::SelectRoot(alpha) => { + s.selected = Some(alpha); + s.selected_source = None; // cargando… + } + Msg::SourceLoaded { alpha, result } => { + // Race-protect: si el usuario clickeó otra raíz mientras + // el thread cargaba la primera, descartamos el resultado. + if s.selected == Some(alpha) { + s.selected_source = Some(result); + } + } + Msg::DeselectRoot => { + s.selected = None; + s.selected_source = None; + } + Msg::VerifyAll => { + // Limpia las marcas previas; el chasis dispara el trabajo + // y mandará VerifyAllReady. + if let Some(snap) = &mut s.snapshot { + for row in &mut snap.recent { + row.verified = None; + } + } + } + Msg::VerifyAllReady(results) => { + if let Some(snap) = &mut s.snapshot { + use std::collections::HashMap; + let by_hash: HashMap<_, _> = results.into_iter().collect(); + for row in &mut snap.recent { + if let Some(ok) = by_hash.get(&row.alpha) { + row.verified = Some(*ok); + } + } + } + } + } + s +} + +/// Reconstruye cada raíz visible y la verifica con `verify_root_alpha`. +/// Bloqueante — corre en un thread del host disparado por `VerifyAll`. +pub fn verify_all_blocking( + repo_path: &std::path::Path, + alphas: &[ContentHash], +) -> Vec<(ContentHash, bool)> { + let inner = repo_path.join(REPO_SUBDIR); + let repo = match PersistentRepo::open(&inner) { + Ok(r) => r, + Err(_) => return alphas.iter().map(|a| (*a, false)).collect(), + }; + let mut out = Vec::with_capacity(alphas.len()); + for &alpha in alphas { + let ok = match repo.roots.get(&alpha) { + Ok(Some((struct_hash, _))) => match repo.nodes.reconstruct(&struct_hash) { + Ok(Some(node)) => { + minga_core::alpha::verify_root_alpha(&node, &alpha).is_some() + } + _ => false, + }, + _ => false, + }; + out.push((alpha, ok)); + } + out +} + +/// Lee el `StoredNode` raíz y devuelve la fuente reconstruida +/// (`render_source`). Bloqueante — pensado para correr en un thread +/// del host como respuesta a [`Msg::SelectRoot`]. +pub fn load_root_source( + repo_path: &std::path::Path, + alpha: ContentHash, +) -> Result { + let inner = repo_path.join(REPO_SUBDIR); + let repo = PersistentRepo::open(&inner).map_err(|e| format!("open sled: {e}"))?; + let struct_hash = match repo.roots.get(&alpha).map_err(|e| e.to_string())? { + Some((sh, _)) => sh, + None => return Err(format!("α-hash {alpha} no es una raíz registrada")), + }; + let node = repo + .nodes + .reconstruct(&struct_hash) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("struct-hash {struct_hash} no está en el grafo"))?; + Ok(minga_vfs_render_source(&node)) +} + +/// Helper local — `minga-vfs::render_source` reexportado para no +/// agregar otra dep aquí. La función vive en `minga-vfs`. +fn minga_vfs_render_source(node: &minga_core::SemanticNode) -> String { + minga_vfs::render_source(node) +} + +/// Lee el repo Minga en `repo_path/` y devuelve counts + +/// últimas raíces. Bloqueante — pensado para correr en un thread del +/// host. Si el directorio no existe, devuelve `Err` con mensaje +/// explicativo (no panic). +pub fn load_snapshot(repo_path: &std::path::Path) -> Result { + let inner = repo_path.join(REPO_SUBDIR); + if !inner.exists() { + return Err(format!( + "no hay repo Minga en {} (esperaba {})", + repo_path.display(), + inner.display() + )); + } + let repo = PersistentRepo::open(&inner).map_err(|e| format!("open sled: {e}"))?; + let nodes = repo.nodes.len(); + let attestations = repo.attestations.len(); + let mst_keys = repo.mst.len(); + let roots = repo.roots.len(); + + let recent: Vec = repo + .roots + .iter() + .filter_map(|r| r.ok()) + .take(RECENT_LIMIT) + .map(|(alpha, _struct, dialect)| { + // Una raíz cuenta como retractada si su tree de retracciones + // tiene al menos una entrada para su α-hash. Errores de + // sled se tratan como "no retractada" (best-effort UI). + let retracted = repo + .retractions + .get(&alpha) + .map(|rs| !rs.is_empty()) + .unwrap_or(false); + RootRow { + alpha, + dialect: dialect.map(|d| d.name()), + verified: None, + retracted, + } + }) + .collect(); + + Ok(RepoSnapshot { + roots, + nodes, + attestations, + mst_keys, + recent, + }) +} + +// ─── Vista ────────────────────────────────────────────────────────── + +/// Renderiza el tab del módulo. `lift` mapea `Msg` del módulo al +/// `ShellMsg` del chasis (cierre que el host construye según el slot). +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> ShellMsg + Clone + 'static, +) -> View { + let mut children: Vec> = Vec::new(); + + children.push(header_row(state, theme)); + + if let Some(e) = &state.error { + children.push(text_row(e, theme.fg_muted, theme)); + } else if let Some(snap) = &state.snapshot { + children.push(counts_row(snap, theme)); + children.push(separator(theme)); + for row in &snap.recent { + let is_selected = state.selected == Some(row.alpha); + let lift_click = lift.clone(); + let alpha = row.alpha; + children.push(root_row( + row, + theme, + is_selected, + move || lift_click(Msg::SelectRoot(alpha)), + )); + } + if snap.recent.is_empty() { + children.push(text_row( + "(sin raíces — corré `minga ingest`)", + theme.fg_muted, + theme, + )); + } + + // Panel inferior con la fuente reconstruida de la raíz + // seleccionada (si la hay). + if state.selected.is_some() { + children.push(separator(theme)); + children.push(selected_source_panel(state, theme, lift.clone())); + } + } else { + children.push(text_row("cargando…", theme.fg_muted, theme)); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(10.0_f32), + bottom: length(12.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(4.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(children) +} + +fn header_row(state: &State, theme: &Theme) -> View { + let title = format!( + "Minga · {} · {}", + state.source.label(), + state.repo_path.display() + ); + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(title, 12.0, theme.fg_text, Alignment::Start) +} + +fn counts_row(snap: &RepoSnapshot, theme: &Theme) -> View { + let s = format!( + "raíces: {} · nodos: {} · atestaciones: {} · mst: {}", + snap.roots, snap.nodes, snap.attestations, snap.mst_keys + ); + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(s, 11.0, theme.fg_text, Alignment::Start) +} + +fn root_row( + row: &RootRow, + theme: &Theme, + is_selected: bool, + on_click: impl FnOnce() -> M, +) -> View { + let alpha_hex = row.alpha.to_string(); + let short: String = alpha_hex.chars().take(16).collect(); + let dialect = row.dialect.unwrap_or("?"); + let marker = if is_selected { "▶ " } else { " " }; + // Marca de verificación: `·` = no verificado, `✓` = OK, `✘` = inconsistente. + let v = match row.verified { + None => "·", + Some(true) => "✓", + Some(false) => "✘", + }; + // Dot de retracción: punto sólido si al menos un autor retractó. + // Sirve como señal visual rápida — no implica que la raíz esté + // borrada (las atestaciones originales se preservan). + let retract = if row.retracted { "● " } else { " " }; + let line = format!("{marker}{retract}{v} {short} {dialect}"); + let fg = if row.retracted { + theme.fg_destructive + } else { + theme.fg_text + }; + let bg = if is_selected { + theme.bg_selected + } else { + theme.bg_panel + }; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + padding: Rect { + left: length(4.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(bg) + .hover_fill(theme.bg_row_hover) + .text_aligned(line, 11.0, fg, Alignment::Start) + .on_click(on_click()) +} + +/// Panel inferior con la fuente reconstruida de la raíz seleccionada. +/// Muestra "cargando…" mientras el thread del chasis trae el contenido, +/// o el render canónico cuando llega. +fn selected_source_panel( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> ShellMsg + Clone + 'static, +) -> View { + let alpha_short: String = state + .selected + .map(|a| a.to_string().chars().take(16).collect()) + .unwrap_or_default(); + let close_lift = lift.clone(); + let header = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + padding: Rect { + left: length(4.0_f32), + right: length(4.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .hover_fill(theme.bg_button_hover) + .text_aligned( + format!("· fuente de {alpha_short} — click para cerrar"), + 11.0, + theme.fg_muted, + Alignment::Start, + ) + .on_click(close_lift(Msg::DeselectRoot)); + + let body_text = match &state.selected_source { + None => "cargando…".to_string(), + Some(Ok(src)) => src.clone(), + Some(Err(e)) => format!("✘ error: {e}"), + }; + let body = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(4.0_f32), + bottom: length(8.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .text_aligned(body_text, 11.0, theme.fg_text, Alignment::Start); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + ..Default::default() + }) + .children(vec![header, body]) +} + +fn text_row( + msg: &str, + color: llimphi_ui::llimphi_raster::peniko::Color, + _theme: &Theme, +) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .text_aligned(msg.to_string(), 11.0, color, Alignment::Start) +} + +fn separator(theme: &Theme) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.border) +} + +// ─── contributions ────────────────────────────────────────────────── + +pub fn contributions(state: &State) -> ModuleContributions { + let counter = state.roots_count.clone(); + let monitor = MonitorSpec { + id: "minga.roots", + label: "minga · raíces".to_string(), + accent: Rgb::new(0xB4, 0x8E, 0xAD), + history_capacity: 60, + period_secs: 5.0, + sampler: Box::new(move || { + let n = *counter.lock().unwrap(); + Sample::new(n as f32, format!("{n} raíces")) + }), + }; + + ModuleContributions { + monitors: vec![monitor], + shortcuts: vec![ + ShortcutSpec::module_action("Refresh", "minga.refresh") + .with_hint("Relee el repo Minga del cwd"), + ShortcutSpec::module_action("Verify", "minga.verify_all") + .with_hint("Recomputa el α-hash de cada raíz visible y marca consistencia"), + ], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_is_stable() { + assert_eq!(ID, "minga"); + } + + #[test] + fn dispatch_known_refresh() { + assert!(matches!(dispatch("minga.refresh"), Some(Msg::Refresh))); + } + + #[test] + fn dispatch_unknown_returns_none() { + assert!(dispatch("foo.bar").is_none()); + assert!(dispatch("matilda.refresh").is_none()); + } + + #[test] + fn load_snapshot_errors_on_missing_repo() { + let p = std::env::temp_dir().join(format!( + "shuma-module-minga-missing-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let err = load_snapshot(&p).unwrap_err(); + assert!(err.contains("no hay repo"), "msg debe explicar: {err}"); + } + + #[test] + fn snapshot_ready_updates_counts_and_clears_error() { + let mut s = State::with_repo_path(Source::Local, PathBuf::from("/tmp/nope")); + s.error = Some("anterior".into()); + let snap = RepoSnapshot { + roots: 7, + nodes: 100, + attestations: 7, + mst_keys: 7, + recent: vec![], + }; + let s2 = update(s, Msg::SnapshotReady(Ok(snap))); + assert_eq!(s2.roots_count(), 7); + assert!(s2.error.is_none()); + assert_eq!(s2.snapshot.unwrap().roots, 7); + } + + #[test] + fn snapshot_error_sets_error_only() { + let s = State::with_repo_path(Source::Local, PathBuf::from("/tmp/nope")); + let s2 = update(s, Msg::SnapshotReady(Err("boom".to_string()))); + assert_eq!(s2.error.as_deref(), Some("boom")); + assert!(s2.snapshot.is_none()); + } + + #[test] + fn load_snapshot_marks_retracted_roots() { + // Montamos un repo con dos raíces: a una le firmamos una + // retracción, a la otra no. El snapshot debe reflejar la + // diferencia en el flag `retracted`. + use minga_core::{alpha::hash_alpha_with, parse, Keypair, Retraction}; + + let base = std::env::temp_dir().join(format!( + "shuma-module-minga-retract-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let inner = base.join(REPO_SUBDIR); + std::fs::create_dir_all(&inner).unwrap(); + + let repo = PersistentRepo::open(&inner).unwrap(); + let kp = Keypair::from_seed(&[42u8; 32]); + + // Raíz 1: con retracción. + let n1 = parse::rust("fn one() -> i32 { 1 }").unwrap(); + use minga_store::node_store::SledNodeStore as _SLN; + let _ = _SLN::put(&repo.nodes, &n1).unwrap(); + let alpha1 = hash_alpha_with(minga_core::parse::Dialect::Rust, &n1); + let struct1 = minga_core::cas::hash_node(&n1); + repo.roots + .put(alpha1, struct1, minga_core::parse::Dialect::Rust) + .unwrap(); + repo.mst.insert(alpha1).unwrap(); + repo.retractions + .add(Retraction::create(&kp, alpha1)) + .unwrap(); + + // Raíz 2: sin retracción. + let n2 = parse::rust("fn two() -> i32 { 2 }").unwrap(); + let _ = _SLN::put(&repo.nodes, &n2).unwrap(); + let alpha2 = hash_alpha_with(minga_core::parse::Dialect::Rust, &n2); + let struct2 = minga_core::cas::hash_node(&n2); + repo.roots + .put(alpha2, struct2, minga_core::parse::Dialect::Rust) + .unwrap(); + repo.mst.insert(alpha2).unwrap(); + + repo.flush().unwrap(); + drop(repo); + + let snap = load_snapshot(&base).unwrap(); + assert_eq!(snap.roots, 2); + assert_eq!(snap.recent.len(), 2); + + let row1 = snap.recent.iter().find(|r| r.alpha == alpha1).unwrap(); + let row2 = snap.recent.iter().find(|r| r.alpha == alpha2).unwrap(); + assert!(row1.retracted, "raíz con retracción debe marcarse"); + assert!(!row2.retracted, "raíz sin retracción no debe marcarse"); + + // Cleanup. + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module-shell/Cargo.toml new file mode 100644 index 0000000..38150c2 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "shuma-module-shell" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-module-shell — el shell interactivo (input + runs + historial) como un módulo enchufable a `shuma-shell-llimphi`. Vista placeholder por ahora; la migración del REPL GPUI llega aparte." + +[dependencies] +shuma-module = { path = "../shuma-module" } +shuma-exec = { path = "../shuma-exec" } +shuma-remote-exec = { path = "../shuma-remote-exec" } +shuma-protocol = { path = "../shuma-protocol" } +shuma-link = { path = "../shuma-link" } +shuma-line = { path = "../shuma-line" } +shuma-history = { path = "../shuma-history" } +shuma-intent = { path = "../shuma-intent" } +shuma-infer = { path = "../shuma-infer" } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-icons = { workspace = true } +llimphi-widget-text-input = { workspace = true } +vt100 = { workspace = true } +arboard = { workspace = true } + +[dev-dependencies] +png = { workspace = true } +pollster = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/LEEME.md b/02_ruway/shuma/sandbox/shuma-module-shell/LEEME.md new file mode 100644 index 0000000..bf16be6 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/LEEME.md @@ -0,0 +1,9 @@ +# shuma-module-shell + +> Módulo shell (Main slot) de [shuma](../../README.md). + +Implementa el slot principal: prompt + output stream + readline. Reusa [`shuma-shell-render`](../shuma-shell-render/README.md). + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`shuma-shell-render`](../shuma-shell-render/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/README.md b/02_ruway/shuma/sandbox/shuma-module-shell/README.md new file mode 100644 index 0000000..021fa60 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/README.md @@ -0,0 +1,9 @@ +# shuma-module-shell + +> Shell module (Main slot) of [shuma](../../README.md). + +Implements the main slot: prompt + output stream + readline. Reuses [`shuma-shell-render`](../shuma-shell-render/README.md). + +## Deps + +- [`shuma-module`](../shuma-module/README.md), [`shuma-shell-render`](../shuma-shell-render/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs new file mode 100644 index 0000000..a724d5e --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_ls.rs @@ -0,0 +1,138 @@ +//! Reproducción headless de un `ls` largo en estado estable (viewport ya +//! medido): comprueba si el output se acota y baja-alinea, o si aplasta el +//! input. `cargo run -p shuma-module-shell --example dump_ls -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +const W: u32 = 1000; +const H: u32 = 640; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "ls.png".to_string()); + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + state.input.set_text("cat archivo_03.rs"); + + use shuma_module_shell::OutputLine; + let block = 1u64; + let mut push = |state: &mut shuma_module_shell::State, mut l: OutputLine| { + l.block = block; + state.output.push(l); + }; + push(&mut state, OutputLine::prompt("$ ls -la")); + for i in 0..50 { + push( + &mut state, + OutputLine::stdout(format!("-rw-r--r-- 1 sergio sergio {:>6} archivo_{:02}.rs", i * 137, i)), + ); + } + push(&mut state, OutputLine::notice("✔ exit 0")); + state.block_seq = block; + state.current_block = block; + // Estado estable: el painter ya midió el viewport en un frame previo. + *state.out_viewport_h.lock().unwrap() = 480.0; + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("dump-ls"), + size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + write_png(&hal, &target, &out); + eprintln!("dump_ls: escrito {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::Maintain::Wait); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs new file mode 100644 index 0000000..8ec79d0 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/examples/dump_shell.rs @@ -0,0 +1,181 @@ +//! Volcado headless del view del shell a PNG: monta el `View` del módulo, +//! computa el layout y lo pinta a una `vello::Scene`, luego lee la textura. +//! Sirve para VER qué renderiza el shell sin levantar ventana (llvmpipe). +//! +//! `cargo run -p shuma-module-shell --example dump_shell -- [out.png]` + +use std::fs::File; +use std::io::BufWriter; + +use llimphi_ui::llimphi_compositor::{measure_text_node, mount, paint}; +use llimphi_ui::llimphi_hal::{wgpu, Hal}; +use llimphi_ui::llimphi_layout::taffy; +use llimphi_ui::llimphi_layout::LayoutTree; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::llimphi_raster::{vello, Renderer}; +use llimphi_ui::llimphi_text::Typesetter; + +const W: u32 = 1000; +const H: u32 = 640; +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +fn main() { + let out = std::env::args().nth(1).unwrap_or_else(|| "shell.png".to_string()); + + // Estado del shell con un comando tipeado (para ver el resaltado) + algo + // de historial (para ver el ghost) si lo hubiera. + let theme = llimphi_theme::Theme::default(); + let mut state = shuma_module_shell::State::new(shuma_module::Source::Local); + state.input.set_text("ls -la | grep foo"); + + // Card de un pipe directo ya ejecutado con captura por etapa (tee): + // la etapa 0 (`ls`) queda desplegada mostrando sus líneas intermedias, + // y la salida final (`grep`) va al cuerpo. Verifica el render del + // desplegable por etapa sin levantar ventana. + use shuma_module_shell::OutputLine; + let block = 1u64; + let push = |state: &mut shuma_module_shell::State, mut l: OutputLine| { + l.block = block; + state.output.push(l); + }; + push(&mut state, OutputLine::prompt("$ ls -la | grep foo")); + push(&mut state, OutputLine::stage_stdout(0, "total 12")); + push(&mut state, OutputLine::stage_stdout(0, "-rw-r--r-- 1 foo.rs")); + push(&mut state, OutputLine::stage_stdout(0, "-rw-r--r-- 1 bar.rs")); + push(&mut state, OutputLine::stdout("-rw-r--r-- 1 foo.rs")); + push(&mut state, OutputLine::notice("✔ exit 0")); + state.block_seq = block; + state.current_block = block; + state.expanded_stages.insert((block, 0)); + // Reprocess armado sobre este bloque → chip resaltado + banner. + state.reprocess_source = Some(block); + // Popup de completado abierto sobre el input. + state.input.set_text("ca"); + state.completion = Some(shuma_line::Completion { + kind: shuma_line::CompletionKind::Command, + candidates: vec![ + "cargo".into(), + "cat".into(), + "cal".into(), + "case".into(), + "captoinfo".into(), + ], + replace_start: 0, + replace_end: 2, + }); + state.completion_index = 1; + // Grupos guardados → panel [RUN] a la izquierda. + state.groups.push(shuma_module_shell::CommandGroup { + name: "build".into(), + lines: vec!["cargo build".into(), "cargo test".into()], + }); + state.groups.push(shuma_module_shell::CommandGroup { + name: "deploy".into(), + lines: vec!["git push".into()], + }); + + let v = shuma_module_shell::view::<()>(&state, &theme, |_m| ()); + + // view → layout → scene (misma secuencia que el eventloop). + let mut layout = LayoutTree::new(); + let mounted = mount(&mut layout, v); + let mut ts = Typesetter::new(); + let computed = { + let tmap = &mounted.text_measures; + layout + .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { + match tmap.get(&nid) { + Some(tm) => measure_text_node(&mut ts, tm, known, avail), + None => taffy::Size::ZERO, + } + }) + .expect("layout") + }; + let mut scene = vello::Scene::new(); + paint(&mut scene, &mounted, &computed, &mut ts, None, None); + + // GPU headless (llvmpipe) → textura → readback → PNG. + let hal = pollster::block_on(Hal::new(None)).expect("hal"); + let mut renderer = Renderer::new(&hal).expect("renderer"); + let target = hal.device.create_texture(&wgpu::TextureDescriptor { + label: Some("dump-shell"), + size: wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FMT, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + renderer + .render_to_view(&hal, &scene, &view, W, H, Color::from_rgba8(20, 20, 26, 255)) + .expect("render_to_view"); + + write_png(&hal, &target, &out); + eprintln!("dump_shell: escrito {out} ({W}x{H})"); +} + +fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { + let unpadded = (W * 4) as usize; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let padded = unpadded.div_ceil(align) * align; + let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: (padded * H as usize) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = hal + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded as u32), + rows_per_image: Some(H), + }, + }, + wgpu::Extent3d { + width: W, + height: H, + depth_or_array_layers: 1, + }, + ); + hal.queue.submit(std::iter::once(enc.finish())); + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + hal.device.poll(wgpu::Maintain::Wait); + rx.recv().unwrap().unwrap(); + let data = slice.get_mapped_range(); + let mut pixels = Vec::with_capacity((W * H * 4) as usize); + for row in 0..H as usize { + let s = row * padded; + pixels.extend_from_slice(&data[s..s + unpadded]); + } + drop(data); + buf.unmap(); + let file = File::create(path).expect("png"); + let mut enc = png::Encoder::new(BufWriter::new(file), W, H); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&pixels).unwrap(); +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs new file mode 100644 index 0000000..9fba5a7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/lib.rs @@ -0,0 +1,1733 @@ +//! `shuma-module-shell` — REPL del shell como módulo enchufable. +//! +//! Núcleo del shell interactivo: cwd + input + ejecución por `shuma-exec` +//! con salida en streaming + buffer de output acotado. Builtins: `cd`, +//! `pwd`, `clear`, `exit` (no-op — el chasis maneja la salida). +//! +//! Diseño del tab: +//! +//! ```text +//! Shell · local · cwd: /home/usuario +//! ┌──────────────────────────────────────────────────────────┐ +//! │ $ ls │ +//! │ Cargo.toml │ +//! │ src │ +//! │ ... │ +//! │ ✔ exit 0 │ +//! └──────────────────────────────────────────────────────────┘ +//! ┌──────────────────────────────────────────────────────────┐ +//! │ $ █ │ +//! └──────────────────────────────────────────────────────────┘ +//! ``` +//! +//! **Ejecución no bloqueante.** Cada submisión lanza `shuma_exec::run` +//! que vuelve de inmediato; el `RunHandle` se guarda en el state. El +//! chasis manda `Msg::Tick` periódicamente y el módulo drena +//! `try_events()` sin bloquear la UI. `sleep 5`, `top` y demás dejan +//! de congelar el shell. Mientras hay un run vivo, Enter encola la +//! nueva línea — el siguiente comando arranca al cerrar el actual. + +#![forbid(unsafe_code)] + +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, AlignItems, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::vello; +use llimphi_ui::llimphi_text::Alignment; +use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View}; +use shuma_exec::{CommandSpec, Exec, Killer, RunEvent, RunHandle, StageSpec}; +use shuma_intent::SessionGraph; +use shuma_line::{LineState, TokenKind}; +use shuma_module::{ModuleContributions, ShortcutSpec, Source}; +use shuma_remote_exec::RemoteRunHandle; +use std::collections::{HashSet, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +/// `id` canónico del módulo. El shumarc lo referencia para activarlo. +pub const ID: &str = "shell"; + +/// Tope de líneas guardadas en el buffer de output — análogo al +/// `cap_log` de matilda. Suficiente para varios runs sin que el panel +/// crezca sin límite. +pub const MAX_OUTPUT_LINES: usize = 500; + +/// Tipo de cada línea del buffer — define el color que la `view` usa. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputKind { + /// El comando tal como lo tipeó el usuario (precede a su output). + Prompt, + /// stdout del comando. + Stdout, + /// stderr del comando. + Stderr, + /// Mensaje del shell mismo (cd, error de spawn, exit status, etc.). + Notice, +} + +/// Una línea del buffer de output con su tipo (para coloreado) y el +/// bloque de comando al que pertenece. El render agrupa las líneas con +/// el mismo `block` en una *card* desplegable (un `$ cmd` + su salida + +/// su exit status). `block == 0` = líneas sueltas sin comando dueño. +#[derive(Debug, Clone)] +pub struct OutputLine { + pub kind: OutputKind, + pub text: String, + /// Bloque de comando. Lo asigna [`State::push_output`] — cada + /// `Prompt` abre uno nuevo (id monotónico) y las siguientes líneas + /// lo heredan. Por defecto `0` (las constructoras no lo conocen). + pub block: u64, + /// Etapa intermedia del pipe que produjo la línea (tee de + /// `shuma-exec`), 0-based. `None` = salida normal (de la última etapa + /// o de un comando suelto). El render guarda estas líneas para el + /// desplegable de su etapa en vez de mezclarlas con el cuerpo. + pub stage: Option, +} + +impl OutputLine { + pub fn prompt(text: impl Into) -> Self { + Self { + kind: OutputKind::Prompt, + text: text.into(), + block: 0, + stage: None, + } + } + pub fn stdout(text: impl Into) -> Self { + Self { + kind: OutputKind::Stdout, + text: text.into(), + block: 0, + stage: None, + } + } + pub fn stderr(text: impl Into) -> Self { + Self { + kind: OutputKind::Stderr, + text: text.into(), + block: 0, + stage: None, + } + } + pub fn notice(text: impl Into) -> Self { + Self { + kind: OutputKind::Notice, + text: text.into(), + block: 0, + stage: None, + } + } + /// Línea capturada de una etapa intermedia del pipe (tee en vivo). Se + /// guarda con su `stage` para el desplegable correspondiente. + pub fn stage_stdout(stage: usize, text: impl Into) -> Self { + Self { + kind: OutputKind::Stdout, + text: text.into(), + block: 0, + stage: Some(stage), + } + } +} + +/// Run vivo: handle de ejecución (local directo o vía daemon), un +/// `Killer` opcional (solo en local — el remoto matamos cerrando el +/// stream) y el comando original (para el notice de cierre). +pub struct ActiveRun { + pub handle: BackendHandle, + /// `Some` cuando el run es local (`shuma-exec::RunHandle.killer()`). + /// `None` cuando es remoto — la cancelación va por `handle.kill()`. + pub killer: Option, + pub command: String, + /// Sesión TUI: emulador vt100 + dims del PTY. `Some` cuando el run + /// arrancó bajo `Exec::Pty` (vim/htop/less/etc.); las teclas van al + /// stdin del PTY y la pantalla se renderiza como grid de celdas. + /// El daemon no soporta PTY remoto todavía — TUIs forzados a local. + pub tui: Option, + /// Bloque de output al que se adjunta TODA la salida de este run — + /// fijo desde el arranque. Sin esto, un comando lento que drena en + /// ticks posteriores se mezclaría con el bloque "actual" (p. ej. un + /// builtin tipeado mientras corre), o un job de fondo se metería en + /// la card del foreground. Cada run vive en su propia card. + pub block: u64, +} + +/// Backend de ejecución abstracto. Local va por `shuma-exec`; Daemon +/// (Unix o TCP) va por `shuma-remote-exec`. La API expuesta al módulo +/// shell (`try_events`, `is_finished`, `kill`, `write_input`, `resize`) +/// es la misma — las operaciones de PTY son no-op en remoto. +pub enum BackendHandle { + Local(RunHandle), + Remote(RemoteRunHandle), +} + +impl BackendHandle { + pub fn try_events(&mut self) -> Vec { + match self { + BackendHandle::Local(h) => h.try_events(), + BackendHandle::Remote(h) => h.try_events(), + } + } + pub fn is_finished(&self) -> bool { + match self { + BackendHandle::Local(h) => h.is_finished(), + BackendHandle::Remote(h) => h.is_finished(), + } + } + pub fn kill(&self) { + match self { + BackendHandle::Local(h) => h.kill(), + BackendHandle::Remote(h) => h.kill(), + } + } + pub fn write_input(&self, bytes: Vec) -> bool { + match self { + BackendHandle::Local(h) => h.write_input(bytes), + // En PTY remoto, el asa enruta las teclas al daemon; en runs + // remotos no-PTY es no-op (devuelve false). + BackendHandle::Remote(h) => h.write_input(bytes), + } + } + pub fn resize(&self, rows: u16, cols: u16) -> bool { + match self { + BackendHandle::Local(h) => h.resize(rows, cols), + BackendHandle::Remote(h) => h.resize(rows, cols), + } + } +} + +/// Skin de render para un programa bajo PTY. `Generic` pinta la grilla +/// vt100 cruda; los demás reconstruyen la pantalla como un card +/// themeable propio del programa (deja de verse "como por un vidrio"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppSkin { + /// Grilla de celdas vt100 (htop, less, man, btop, …). + Generic, + /// vim/nvim/vi: el buffer como texto en la paleta del tema. + Vim, + /// claude code: un card grande que engloba la sesión (por ahora cae + /// al genérico hasta que esté el parser de bloques). + Claude, +} + +/// Elige el skin a partir del nombre del programa (acepta un path — +/// toma el basename). +pub fn app_skin_for(program: &str) -> AppSkin { + let base = program.rsplit('/').next().unwrap_or(program); + match base { + "vi" | "vim" | "nvim" | "view" | "nvi" => AppSkin::Vim, + "claude" => AppSkin::Claude, + _ => AppSkin::Generic, + } +} + +/// Sesión TUI sobre PTY — bufferea el parser vt100 y los dims actuales. +pub struct TuiSession { + pub parser: vt100::Parser, + pub rows: u16, + pub cols: u16, + /// Programa bajo el PTY (basename incluido) — define el skin. + pub program: String, + /// Skin de render elegido al arrancar. + pub skin: AppSkin, +} + +impl TuiSession { + pub fn new(program: &str, rows: u16, cols: u16) -> Self { + Self { + parser: vt100::Parser::new(rows, cols, 0), + rows, + cols, + program: program.to_string(), + skin: app_skin_for(program), + } + } + + /// Cambia las dimensiones del buffer interno del parser. El resize + /// del PTY real (que dispara SIGWINCH al child) lo hace el caller + /// vía `RunHandle::resize`. + pub fn set_size(&mut self, rows: u16, cols: u16) { + if rows == self.rows && cols == self.cols { + return; + } + self.parser.screen_mut().set_size(rows, cols); + self.rows = rows; + self.cols = cols; + } +} + +impl std::fmt::Debug for ActiveRun { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActiveRun") + .field("command", &self.command) + .field("finished", &self.handle.is_finished()) + .field("tui", &self.tui.is_some()) + .finish() + } +} + +/// Dims fijos para el PTY mientras el chasis no exponga el ancho real +/// del panel. 80×24 es el default histórico y vim/htop arrancan bien. +const PTY_ROWS: u16 = 24; +const PTY_COLS: u16 = 80; + +/// Tabla de comandos que pedimos PTY automáticamente. Otros pueden +/// pedirlo con el prefijo `:tui ...`. +const TUI_ALLOWLIST: &[&str] = &[ + "vi", "vim", "nvim", "nano", "emacs", "helix", "hx", "htop", "btop", "top", "less", "more", + "man", "claude", "tig", "tui", "watch", +]; + +/// Selección activa/última en el card de vim, en coordenadas locales px +/// del panel (`ax,ay` = ancla del press; `hx,hy` = cabeza/cursor). +/// `active` = hay un drag en curso. +#[derive(Debug, Clone, Copy)] +pub struct VimSel { + pub ax: f32, + pub ay: f32, + pub hx: f32, + pub hy: f32, + pub active: bool, +} + +#[derive(Clone)] +pub struct State { + pub source: Source, + pub cwd: PathBuf, + pub input: LineState, + pub output: Vec, + pub focused: bool, + /// Run en ejecución, si hay. Cloneable por `Arc>` — la + /// derivación `Clone` del state nos obliga a esto (el chasis clona + /// el state en cada `route_to_instance`). + pub running: Option>>, + /// Cola de líneas pendientes — cuando el usuario presiona Enter + /// mientras hay un run vivo, el nuevo comando entra acá y arranca + /// cuando el actual cierra. + pub queue: VecDeque, + /// Fuente de completion (binarios en `$PATH` + paths bajo cwd). Es + /// `Arc` porque el `complete()` de `shuma-line` la usa por + /// referencia y el state se clona en cada `route_to_instance`. + pub completion_source: Arc, + /// Historial durable de líneas submitted — alimenta ghost + /// suggestion + Up/Down + Ctrl-R fuzzy. + pub history: Arc>, + /// Cursor de navegación del historial. `None` = no navegando. + pub history_cursor: Option, + /// Overlay de búsqueda Ctrl-R activo. `None` = no abierto. + pub history_search: Option, + /// Último rect (w, h) píxel del panel TUI — lo escribe el painter + /// y lo lee `drain_run` para disparar resize si cambia. Cero = + /// "todavía no se pintó". + pub last_tui_rect: Arc>, + /// Métricas reales (char_w, line_h) del monospace del card de vim, + /// medidas por el painter sobre el layout de parley y leídas por + /// `copy_vim_selection`. Cero = todavía sin medir (usar fallback). + pub vim_metrics: Arc>, + /// Jobs en background — arrancados con sufijo `&` en la línea. No + /// son el "foreground" (ese es `running`); su output se mergea al + /// buffer prefijado por `[N]`. Builtins `:jobs`, `:term N`, + /// `:stop N`, `:cont N` operan sobre estos. + pub bg_jobs: Vec>>, + /// Grafo de intenciones de la sesión — alimenta el lienzo de + /// contexto (`shuma-module-canvas`). Cada `start_run` registra un + /// nodo `%cN` y `drain_run` lo cierra con el status del exit. + pub intent_graph: SessionGraph, + /// `%cN` del run en foreground actual; `None` cuando no hay nada + /// corriendo. Se setea en `start_run` y se consume en `drain_run`. + pub current_run_node: Option, + /// Bytes acumulados de stdout+stderr del run actual; se vuelca al + /// nodo del grafo cuando el comando cierra (`complete`). + pub current_run_bytes: u64, + /// Selección del card de vim (drag-to-select). `None` = sin selección. + pub vim_sel: Option, + /// Contador monotónico de bloques de comando. Cada `Prompt` lo + /// incrementa; nunca se reusa, así el colapso sobrevive al capado + /// del buffer (los ids no se reciclan al drenar líneas viejas). + pub block_seq: u64, + /// Bloque al que se adjuntan las líneas nuevas (el último `Prompt`). + pub current_block: u64, + /// Bloques colapsados por el usuario (click en el header de la card). + /// Se renderizan plegados, mostrando sólo el header + un resumen. + pub collapsed: HashSet, + /// Etapas de pipe desplegadas — `(block, stage)`. Click en un chip de + /// etapa alterna la pertenencia; al estar presente se muestran sus + /// líneas capturadas en vivo (tee) bajo la fila de etapas. + pub expanded_stages: HashSet<(u64, usize)>, + /// Patrones de comandos inferidos del historial (`shuma-infer`). Se + /// recalculan al cerrar cada comando y alimentan el ghost con la + /// secuencia predicha (no sólo el historial reciente). Vacío al + /// arrancar y hasta tener suficiente historial. + pub patterns: Vec, + /// Tope de captura de stdout por run, en bytes. `0` = sin tope. Lo fija + /// el builtin `:limit `. + pub capture_limit_bytes: usize, + /// Si volcar a disco la salida que excede el tope (`:spill on`). Sólo + /// tiene efecto con `capture_limit_bytes > 0`. + pub spill: bool, + /// Bloque cuyo stdout alimenta el stdin del próximo run (reprocess — + /// el `%pN` del lienzo). Lo arma el chip ↻ de una card y se consume en + /// el siguiente submit. `None` = sin reprocess armado. + pub reprocess_source: Option, + /// Grupos de comandos guardados con `:save ` — ejecutables por + /// F1..F8 (índice 0-based = número de F menos 1). + pub groups: Vec, + /// Largo del historial en el último `:save` — los comandos desde acá + /// son los que entran al próximo grupo. + pub group_anchor: usize, + /// Completado activo (popup de candidatos). `Some` = popup abierto (Tab + /// con ≥2 opciones); se navega con Tab/flechas y se acepta con Enter. + pub completion: Option, + /// Candidato resaltado dentro del popup de completado. + pub completion_index: usize, + /// Scroll del panel de output, en px medidos desde el fondo. `0` = + /// pegado al fondo (lo último siempre visible, como una terminal). + /// Crece al rodar la rueda hacia arriba (ver historial). Lo clampa + /// la `view` contra el overflow real. + pub scroll_px: f32, + /// Alto del viewport de output (lo publica el painter del panel cada + /// frame; lo lee la `view` y el handler de rueda al frame siguiente). + pub out_viewport_h: Arc>, + /// Overflow vertical del output (content_h − viewport_h, ≥0). Lo + /// publica la `view` y lo usa `Msg::Scroll` para clampar `scroll_px` + /// sin recalcular la geometría en el handler. + pub out_overflow: Arc>, +} + +/// Estado del overlay de búsqueda Ctrl-R. +#[derive(Debug, Clone, Default)] +pub struct HistorySearch { + pub query: String, + pub selected: usize, +} + +/// Grupo de comandos guardado (`:save `) — una secuencia ejecutable +/// como una sola línea (`l1 && l2 && …`) desde una tecla de función. +#[derive(Debug, Clone)] +pub struct CommandGroup { + pub name: String, + pub lines: Vec, +} + +impl State { + pub fn new(source: Source) -> Self { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + let completion_source = Arc::new(ShellSource::new(&cwd)); + let history = Arc::new(Mutex::new(open_history())); + // El anchor de grupos arranca al final del historial durable: el + // primer `:save` agrupa sólo lo tipeado en ESTA sesión, no meses + // de historial persistido. + let group_anchor = history.lock().map(|h| h.len()).unwrap_or(0); + Self { + source, + cwd, + input: LineState::new(), + output: Vec::new(), + focused: true, + running: None, + queue: VecDeque::new(), + completion_source, + history, + history_cursor: None, + history_search: None, + last_tui_rect: Arc::new(Mutex::new((0.0, 0.0))), + vim_metrics: Arc::new(Mutex::new((0.0, 0.0))), + bg_jobs: Vec::new(), + intent_graph: SessionGraph::new(), + current_run_node: None, + current_run_bytes: 0, + vim_sel: None, + block_seq: 0, + current_block: 0, + collapsed: HashSet::new(), + expanded_stages: HashSet::new(), + patterns: Vec::new(), + capture_limit_bytes: 0, + spill: false, + reprocess_source: None, + groups: Vec::new(), + group_anchor, + completion: None, + completion_index: 0, + scroll_px: 0.0, + out_viewport_h: Arc::new(Mutex::new(0.0)), + out_overflow: Arc::new(Mutex::new(0.0)), + } + } + + /// Empuja una línea al buffer asignándole bloque. Cada `Prompt` abre + /// un bloque nuevo (id monotónico); las demás líneas heredan el + /// bloque abierto. El render usa esto para agrupar cada comando con + /// su salida en una card desplegable. + pub(crate) fn push_output(&mut self, mut line: OutputLine) { + if line.kind == OutputKind::Prompt { + self.block_seq += 1; + self.current_block = self.block_seq; + } + line.block = self.current_block; + push_line(&mut self.output, line); + } + + /// Reserva un bloque nuevo sin tocar `current_block` — para runs que + /// drenan asíncronos (foreground lento, jobs de fondo) y necesitan su + /// propia card aunque otros comandos se intercalen mientras tanto. + pub(crate) fn open_block(&mut self) -> u64 { + self.block_seq += 1; + self.block_seq + } + + /// Empuja una línea en un bloque explícito (no en `current_block`). + /// La usa el drenado de runs async para que su salida quede en SU + /// card y no en la del comando que el usuario tipeó mientras tanto. + pub(crate) fn push_in_block(&mut self, block: u64, mut line: OutputLine) { + line.block = block; + push_line(&mut self.output, line); + } + + /// Vacía el buffer y el set de colapsos. No resetea `block_seq` — + /// mantener ids monotónicos es inofensivo y evita reusos. + pub(crate) fn clear_output(&mut self) { + self.output.clear(); + self.collapsed.clear(); + self.expanded_stages.clear(); + self.reprocess_source = None; + self.scroll_px = 0.0; + } + + /// Cantidad de líneas en el buffer — alimenta el monitor. + pub fn output_len(&self) -> usize { + self.output.len() + } + + /// `true` si hay un comando ejecutándose ahora. + pub fn is_running(&self) -> bool { + self.running.is_some() + } + + /// Snapshot del grafo de intenciones — el chasis lo lee cada tick + /// y lo sincroniza al `shuma-module-canvas` activo. + pub fn intent_graph(&self) -> &SessionGraph { + &self.intent_graph + } +} + +/// Fuente de candidatos del shell — implementa +/// [`shuma_line::CompletionSource`]: +/// +/// - `commands()`: escanea `$PATH` la primera vez y cachea el resultado. +/// - `paths(prefix)`: listado del dir derivado del `prefix`, resolviendo +/// relativos contra `cwd`. +#[derive(Debug)] +pub struct ShellSource { + cwd: PathBuf, + commands: std::sync::OnceLock>, +} + +impl ShellSource { + pub fn new(cwd: &std::path::Path) -> Self { + Self { + cwd: cwd.to_path_buf(), + commands: std::sync::OnceLock::new(), + } + } +} + +impl shuma_line::CompletionSource for ShellSource { + fn commands(&self) -> Vec { + self.commands + .get_or_init(|| { + let path = std::env::var_os("PATH").unwrap_or_default(); + let mut out: Vec = Vec::new(); + for dir in std::env::split_paths(&path) { + if let Ok(rd) = std::fs::read_dir(&dir) { + for ent in rd.flatten() { + if let Some(name) = ent.file_name().to_str() { + out.push(name.to_string()); + } + } + } + } + out.sort(); + out.dedup(); + out + }) + .clone() + } + fn paths(&self, prefix: &str) -> Vec { + let (dir_part, file_part) = match prefix.rfind('/') { + Some(i) => (&prefix[..=i], &prefix[i + 1..]), + None => ("", prefix), + }; + let dir: PathBuf = if dir_part.is_empty() { + self.cwd.clone() + } else if dir_part.starts_with('/') { + PathBuf::from(dir_part) + } else if let Some(stripped) = dir_part.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(stripped) + } else { + self.cwd.join(dir_part) + } + } else { + self.cwd.join(dir_part) + }; + let Ok(rd) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); + for ent in rd.flatten() { + let name = match ent.file_name().to_str() { + Some(n) => n.to_string(), + None => continue, + }; + if !name.starts_with(file_part) { + continue; + } + // Ocultos: sólo aparecen si el prefix los pidió explícito. + if name.starts_with('.') && !file_part.starts_with('.') { + continue; + } + let mut full = format!("{dir_part}{name}"); + if ent.file_type().map(|t| t.is_dir()).unwrap_or(false) { + full.push('/'); + } + out.push(full); + } + out.sort(); + out + } +} + +/// Abre el historial en `$XDG_DATA_HOME/shuma/history.jsonl` (o el +/// fallback de `directories`). Si no se puede abrir, devuelve un +/// historial vacío en `/dev/null` — el shell sigue funcionando sin +/// persistencia. +fn open_history() -> shuma_history::History { + if let Some(path) = shuma_history::History::default_path() { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(h) = shuma_history::History::open(&path) { + return h; + } + } + // Fallback: historial en /dev/null (existe siempre, append-only OK). + shuma_history::History::open(std::path::PathBuf::from("/dev/null")) + .unwrap_or_else(|_| panic!("no se pudo abrir ni /dev/null como history")) +} + +#[derive(Debug, Clone)] +pub enum Msg { + /// Tecla recibida desde el chasis. Enter ejecuta, Tab completa, + /// flechas y edición van al `LineState`. + Key(KeyEvent), + /// Click sobre el input box — re-foca (sigue siendo el único + /// campo, pero lo mantenemos por simetría con otros módulos). + FocusInput, + /// Limpia el buffer de output — disparado por el shortcut `Clear` + /// o el builtin `clear`. + Clear, + /// Drena eventos del run activo (si hay) y pinta líneas nuevas. + /// Lo dispara el chasis a alta frecuencia (~100 ms). + Tick, + /// SIGTERM al run activo (Ctrl-C o shortcut `Cancel`). + Cancel, + /// Click en una decoración del output — el dispatch decide la + /// acción (cd, xdg-open, pre-llenar el input, etc.). + OpenDecoration(shuma_line::DecorationKind), + /// Inserta `text` en la posición actual del cursor del input. La + /// dispara el chasis cuando otro módulo (p. ej. `shuma-module-canvas` + /// al clickear un nodo) quiere empujar una referencia `%pN`/`%cN` + /// al REPL. Cierra los overlays de búsqueda y deja el cursor justo + /// después del texto insertado. + InsertAtCursor(String), + /// Pega el clipboard al PTY del TUI activo — click derecho o botón + /// del medio sobre el panel de vim (paste estilo terminal). + VimPaste, + /// Drag de selección sobre el card de vim. `dx`/`dy` = delta desde el + /// evento anterior; `ax`/`ay` = posición del press (local al panel). + VimDrag { + end: bool, + dx: f32, + dy: f32, + ax: f32, + ay: f32, + }, + /// Alterna plegado/desplegado de la card de un comando. La dispara el + /// click en el header de la card (chevron + comando). + ToggleBlock(u64), + /// Rueda del mouse sobre el panel de output. `delta` ya viene en px + /// (positivo = rodar hacia arriba / ver historial). Ajusta `scroll_px`. + Scroll(f32), + /// Re-ejecuta `line` como un comando nuevo — la dispara el click en + /// una etapa de pipe de una card SIN captura en vivo (fallback `sh -c`). + RunLine(String), + /// Alterna el desplegable de una etapa de pipe con captura en vivo + /// (tee). La dispara el click en su chip; muestra/oculta las líneas + /// intermedias ya capturadas sin re-ejecutar nada. + ToggleStage { block: u64, stage: usize }, + /// Arma el reprocess: el stdout del bloque `block` alimentará el stdin + /// del próximo comando. La dispara el chip ↻ de una card. Si ya estaba + /// armado el mismo bloque, lo desarma (toggle). + SetReprocess(u64), + /// Ejecuta el grupo guardado de índice `idx` (0-based). La dispara el + /// click en su card del panel de grupos (equivale a la tecla F{idx+1}). + RunGroup(usize), +} + +mod update; +mod view; + +pub use update::*; +pub use view::*; + +pub fn contributions(_state: &State) -> ModuleContributions { + ModuleContributions { + monitors: vec![], + shortcuts: vec![ + ShortcutSpec::module_action("Clear", "shell.clear") + .with_hint("Vacía el buffer de output"), + ShortcutSpec::module_action("Cancel", "shell.cancel") + .with_hint("SIGTERM al comando actual"), + ], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llimphi_ui::Modifiers; + + fn ev(key: Key, text: Option<&str>) -> KeyEvent { + KeyEvent { + key, + state: KeyState::Pressed, + text: text.map(|s| s.to_string()), + modifiers: Modifiers::default(), + repeat: false, + } + } + + /// Aplica `Msg::Tick` hasta que el run vivo se cierre (o se acabe el + /// presupuesto). Imita lo que el chasis hace a 100 ms entre ticks. + fn drain_until_idle(mut s: State) -> State { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while s.is_running() { + s = update(s, Msg::Tick); + if std::time::Instant::now() > deadline { + panic!("run no terminó en 10s"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + // Un Tick más por si quedó algo en el canal después del Exited. + update(s, Msg::Tick) + } + + #[test] + fn id_is_stable() { + assert_eq!(ID, "shell"); + } + + #[test] + fn placeholder_state_constructs() { + let s = State::new(Source::Local); + assert!(s.output.is_empty()); + assert!(s.cwd.is_absolute() || s.cwd == PathBuf::from("/")); + } + + #[test] + fn pwd_builtin_writes_cwd() { + let mut s = State::new(Source::Local); + s.input.set_text("pwd"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.starts_with("$ pwd"))); + assert!(s.output.iter().any(|l| l.kind == OutputKind::Stdout)); + } + + #[test] + fn clear_builtin_empties_output() { + let mut s = State::new(Source::Local); + s.input.set_text("pwd"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(!s.output.is_empty()); + s.input.set_text("clear"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.is_empty()); + } + + #[test] + fn clear_msg_empties_output() { + let mut s = State::new(Source::Local); + s.output.push(OutputLine::stdout("hola")); + s = update(s, Msg::Clear); + assert!(s.output.is_empty()); + } + + #[test] + fn cd_to_root_changes_cwd() { + let mut s = State::new(Source::Local); + s.input.set_text("cd /"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.cwd, PathBuf::from("/")); + } + + #[test] + fn cd_to_nonexistent_logs_error() { + let mut s = State::new(Source::Local); + s.input.set_text("cd /nope/this/does/not/exist"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.starts_with("cd:"))); + } + + #[test] + fn external_command_captures_stdout() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("echo hola_mundo"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running(), "Enter debe arrancar el run"); + s = drain_until_idle(s); + let combined: Vec = s.output.iter().map(|l| l.text.clone()).collect(); + assert!( + combined.iter().any(|t| t == "hola_mundo"), + "esperaba stdout 'hola_mundo' en {combined:?}" + ); + assert!(combined.iter().any(|t| t == "✔ exit 0")); + } + + #[test] + fn external_command_failure_writes_exit_nonzero() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("false"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + assert!(s.output.iter().any(|l| l.text.starts_with("✘ exit"))); + } + + #[test] + fn long_running_command_does_not_block_update() { + // `sleep 0.3` debería volver de `update` inmediatamente (no + // bloquear ~300 ms como con `Command::output`). Si el spawn es + // no-bloqueante, `update` retorna en pocos milisegundos. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 0.3"); + let t0 = std::time::Instant::now(); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + let elapsed = t0.elapsed(); + assert!( + elapsed.as_millis() < 100, + "update bloqueó {elapsed:?} — debería volver al instante" + ); + assert!(s.is_running(), "el sleep debe seguir vivo tras Enter"); + s = drain_until_idle(s); + assert!(s.output.iter().any(|l| l.text == "✔ exit 0")); + } + + #[test] + fn second_enter_queues_while_busy() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 0.2"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running()); + s.input.set_text("echo segunda"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.queue.len(), 1, "segunda línea debe quedar en cola"); + s = drain_until_idle(s); + // Tras drenar, la cola arrancó y ya cerró el segundo run. + assert_eq!(s.queue.len(), 0); + let combined: Vec = s.output.iter().map(|l| l.text.clone()).collect(); + assert!(combined.iter().any(|t| t == "segunda"), "{combined:?}"); + } + + #[test] + fn cancel_terminates_active_run() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 30"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running()); + // El coordinador de `shuma-exec` puebla `Killer.children` en + // background — un Cancel inmediato podría llegar antes y la + // señal caería en el vacío. Esperar a que aparezca el PID. + let arc = s.running.as_ref().unwrap().clone(); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500); + while std::time::Instant::now() < deadline { + let has_pid = arc + .lock() + .unwrap() + .killer + .as_ref() + .map(|k| !k.pids().is_empty()) + .unwrap_or(false); + if has_pid { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!( + arc.lock() + .unwrap() + .killer + .as_ref() + .map(|k| !k.pids().is_empty()) + .unwrap_or(false), + "el coordinador no expuso el PID en 500ms" + ); + s = update(s, Msg::Cancel); + s = drain_until_idle(s); + assert!(!s.is_running(), "sleep 30 debe morir al cancelar"); + assert!(s.output.iter().any(|l| l.text.starts_with("⏹ cancel"))); + } + + #[test] + fn empty_submit_does_nothing_but_clears_input() { + let mut s = State::new(Source::Local); + s.input.set_text(" "); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.is_empty()); + assert!(s.input.text().is_empty()); + } + + #[test] + fn output_buffer_caps_at_max() { + let mut buf: Vec = Vec::new(); + for i in 0..MAX_OUTPUT_LINES + 50 { + push_line(&mut buf, OutputLine::stdout(format!("línea {i}"))); + } + assert_eq!(buf.len(), MAX_OUTPUT_LINES); + assert!(buf[0].text.contains("50")); + } + + #[test] + fn tab_completion_inserts_unique_candidate() { + // Si el prefijo tiene un único match, Tab debe completarlo. + let mut s = State::new(Source::Local); + s.input.set_text("ec"); + // Forzar un source determinístico para no depender de $PATH. + struct Fixed; + impl shuma_line::CompletionSource for Fixed { + fn commands(&self) -> Vec { + vec!["echo".into()] + } + fn paths(&self, _: &str) -> Vec { + vec![] + } + } + s.completion_source = Arc::new(ShellSource::new(&s.cwd)); + // Bypassear: aplicamos completion manualmente con el Fixed source, + // ya que apply_completion_msg usa s.completion_source. + let comp = s.input.complete(&Fixed); + let candidate = comp.candidates.first().cloned().unwrap_or_default(); + s.input.apply_completion(&comp, &candidate); + assert_eq!(s.input.text(), "echo"); + } + + #[test] + fn common_prefix_returns_longest_shared_start() { + let xs: Vec = vec!["cargo".into(), "cargo-edit".into(), "cargot".into()]; + assert_eq!(common_prefix(&xs), "cargo"); + let ys: Vec = vec!["abc".into(), "xyz".into()]; + assert_eq!(common_prefix(&ys), ""); + } + + #[test] + fn arrow_up_walks_history_backwards() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + // Insertar entradas a mano vía History (no via run_submitted, que + // dispararía procesos reales). + { + let mut h = s.history.lock().unwrap(); + let _ = h.append(shuma_history::Entry::new("uno", "/", 1)); + let _ = h.append(shuma_history::Entry::new("dos", "/", 2)); + } + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); + assert_eq!(s.input.text(), "dos"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); + assert_eq!(s.input.text(), "uno"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); + assert_eq!(s.input.text(), "dos"); + } + + #[test] + fn ctrl_r_opens_search_overlay() { + let mut s = State::new(Source::Local); + let ctrl_r = KeyEvent { + key: Key::Character("r".into()), + state: KeyState::Pressed, + text: Some("r".into()), + modifiers: Modifiers { + ctrl: true, + ..Default::default() + }, + repeat: false, + }; + s = update(s, Msg::Key(ctrl_r)); + assert!(s.history_search.is_some()); + } + + #[test] + fn ghost_extends_from_history_when_prefix_matches() { + let mut s = State::new(Source::Local); + { + let mut h = s.history.lock().unwrap(); + let _ = h.append(shuma_history::Entry::new("cargo build --release", "/", 1)); + } + s.input.set_text("cargo bu"); + let g = current_ghost(&s); + // Devuelve el sufijo que falta para llegar a la línea histórica. + assert_eq!(g.as_deref(), Some("ild --release")); + } + + #[test] + fn build_spec_routes_known_tui_command_to_pty() { + let (spec, tui) = build_spec("vim README.md", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Pty { .. })); + assert!(tui.is_some()); + } + + #[test] + fn build_spec_routes_plain_command_to_shell() { + let (spec, tui) = build_spec("ls -la", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Shell { .. })); + assert!(tui.is_none()); + } + + #[test] + fn build_spec_routes_simple_pipe_to_direct_with_capture() { + // Un pipe simple corre directo (sin bash) y con captura por etapa. + let (spec, tui) = build_spec("ls -la | grep foo", "/"); + match &spec.exec { + shuma_exec::Exec::Direct { stages } => { + assert_eq!(stages.len(), 2, "dos etapas"); + assert_eq!(stages[0].program, "ls"); + assert_eq!(stages[1].program, "grep"); + } + other => panic!("esperaba Exec::Direct, fue {other:?}"), + } + assert!(spec.capture_stages, "el pipe directo activa el tee"); + assert!(tui.is_none()); + } + + #[test] + fn build_spec_pipe_with_quotes_falls_back_to_shell() { + // `shuma_line::Stage` no recoge StringLit en args, así que un pipe + // con comillas debe ir a `sh -c` o perdería el argumento citado. + let (spec, _) = build_spec("echo 'a | b' | cat", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Shell { .. })); + assert!(!spec.capture_stages); + } + + #[test] + fn build_spec_pipe_with_glob_falls_back_to_shell() { + let (spec, _) = build_spec("ls *.rs | cat", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Shell { .. })); + } + + #[test] + fn simple_pipe_stages_rejects_single_command() { + // Un único comando no gana nada del modo directo (no hay tubería + // que interceptar) → `None`, cae a `sh -c`. + assert!(simple_pipe_stages("ls -la").is_none()); + } + + #[test] + fn simple_pipe_stages_rejects_trailing_pipe() { + // Etapa sin comando (línea incompleta) → None. + assert!(simple_pipe_stages("ls |").is_none()); + } + + #[test] + fn piped_command_captures_intermediate_stage_output() { + // `echo hola | cat`: stage0 (echo) se captura en vivo como una + // OutputLine con stage=Some(0); la salida final (cat) sale como + // stdout normal (stage None). + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("echo hola | cat"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.is_running(), "el pipe debe arrancar un run"); + s = drain_until_idle(s); + let stage0: Vec<&OutputLine> = s + .output + .iter() + .filter(|l| l.stage == Some(0)) + .collect(); + assert!( + stage0.iter().any(|l| l.text == "hola"), + "esperaba 'hola' capturado de la etapa 0, output: {:?}", + s.output.iter().map(|l| (l.stage, &l.text)).collect::>() + ); + // La salida final (cat) llega como stdout normal sin stage. + assert!(s + .output + .iter() + .any(|l| l.stage.is_none() && l.text == "hola")); + assert!(s.output.iter().any(|l| l.text == "✔ exit 0")); + } + + #[test] + fn infer_predicts_next_command_in_a_repeated_sequence() { + // Historial con el patrón `git pull` → `make` repetido dos veces y + // un `git pull` final: el motor debe predecir `make` como + // continuación. cwd `/tmp/...` sin marcadores → sin gating. + let mut s = State::new(Source::Local); + let dir = "/tmp/shuma-infer-pred-test"; + { + let mut h = s.history.lock().unwrap(); + for (i, line) in ["git pull", "make", "git pull", "make", "git pull"] + .iter() + .enumerate() + { + let _ = h.append(shuma_history::Entry::new(*line, dir, i as u64)); + } + } + refresh_patterns(&mut s); + assert!(!s.patterns.is_empty(), "debe emerger el patrón git→make"); + // La continuación predicha empieza por `make` (puede seguir con el + // resto del patrón más largo, p. ej. `make && git pull`). + let pred = predicted_sequence(&s).expect("predice una continuación"); + assert!( + pred.starts_with("make"), + "tras `git pull` predice `make…`, fue {pred:?}" + ); + } + + #[test] + fn ghost_uses_prediction_before_history() { + // Con el patrón aprendido, tipear `ma` debe sugerir `ke` (de la + // predicción `make`), aunque el historial no tenga un match mejor. + let mut s = State::new(Source::Local); + let dir = "/tmp/shuma-infer-ghost-test"; + { + let mut h = s.history.lock().unwrap(); + for (i, line) in ["git pull", "make", "git pull", "make", "git pull"] + .iter() + .enumerate() + { + let _ = h.append(shuma_history::Entry::new(*line, dir, i as u64)); + } + } + refresh_patterns(&mut s); + s.input.set_text("ma"); + assert_eq!(current_ghost(&s).as_deref(), Some("ke")); + } + + #[test] + fn git_branch_reads_head_ref() { + // `.git/HEAD` con `ref: refs/heads/` → Some(rama). Usamos un + // tmpdir aislado para no depender del repo real. + let base = std::env::temp_dir().join(format!("shuma-gb-{}", std::process::id())); + let git = base.join(".git"); + std::fs::create_dir_all(&git).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/feature/x\n").unwrap(); + // Desde un subdirectorio: debe subir hasta encontrar `.git`. + let sub = base.join("sub/dir"); + std::fs::create_dir_all(&sub).unwrap(); + assert_eq!(git_branch(&sub).as_deref(), Some("feature/x")); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn git_branch_none_outside_repo() { + let base = std::env::temp_dir().join(format!("shuma-nogit-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + assert_eq!(git_branch(&base), None); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn limit_builtin_sets_capture_bytes() { + let mut s = State::new(Source::Local); + s.input.set_text(":limit 5"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.capture_limit_bytes, 5 * 1024 * 1024); + assert!(!s.is_running(), "`:limit` no spawnea proceso"); + // `:limit 0` quita el tope. + s.input.set_text(":limit 0"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.capture_limit_bytes, 0); + } + + #[test] + fn spill_builtin_toggles_flag() { + let mut s = State::new(Source::Local); + s.input.set_text(":spill on"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.spill); + s.input.set_text(":spill off"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(!s.spill); + } + + #[test] + fn save_group_captures_recent_commands() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + // Dos comandos reales (no meta) + un :save. + for line in ["echo uno", "echo dos"] { + s.input.set_text(line); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + } + s.input.set_text(":save build"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.groups.len(), 1); + assert_eq!(s.groups[0].name, "build"); + assert_eq!(s.groups[0].lines, vec!["echo uno", "echo dos"]); + // El anchor avanzó: un segundo :save sin comandos nuevos no agrupa. + s.input.set_text(":save vacio"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.groups.len(), 1, "no se crea grupo vacío"); + } + + #[test] + fn run_group_msg_executes_group() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.groups.push(CommandGroup { + name: "g".into(), + lines: vec!["echo desde_panel".into()], + }); + s = update(s, Msg::RunGroup(0)); + s = drain_until_idle(s); + assert!(s.output.iter().any(|l| l.text == "desde_panel")); + // Índice fuera de rango: no-op. + s = update(s, Msg::RunGroup(9)); + assert!(!s.is_running()); + } + + #[test] + fn fkey_runs_saved_group() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + // F1 sin grupos: no hace nada. + s = update(s, Msg::Key(ev(Key::Named(NamedKey::F1), None))); + assert!(!s.is_running()); + // Guardamos un grupo de un comando y lo corremos con F1. + s.groups.push(CommandGroup { + name: "g".into(), + lines: vec!["echo desde_f1".into()], + }); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::F1), None))); + s = drain_until_idle(s); + assert!(s.output.iter().any(|l| l.text == "desde_f1")); + } + + #[test] + fn reprocess_feeds_block_stdout_as_stdin() { + // Corre `printf "b\\na\\nc\\n"`, arma reprocess sobre su bloque, y + // corre `sort`: debe recibir esa salida por stdin y ordenarla. + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("printf 'b\\na\\nc\\n'"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + let src_block = s.output.iter().find(|l| l.text == "b").unwrap().block; + s = update(s, Msg::SetReprocess(src_block)); + assert_eq!(s.reprocess_source, Some(src_block)); + s.input.set_text("sort"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.reprocess_source.is_none(), "el submit consume el reprocess"); + s = drain_until_idle(s); + // La salida de `sort` (en su propio bloque) está ordenada: a,b,c. + let sorted: Vec = s + .output + .iter() + .filter(|l| l.block != src_block && l.kind == OutputKind::Stdout) + .map(|l| l.text.clone()) + .collect(); + assert_eq!(sorted, vec!["a", "b", "c"], "sort recibió el stdin reprocesado"); + } + + #[test] + fn set_reprocess_toggles_off_same_block() { + let mut s = State::new(Source::Local); + s = update(s, Msg::SetReprocess(3)); + assert_eq!(s.reprocess_source, Some(3)); + s = update(s, Msg::SetReprocess(3)); + assert_eq!(s.reprocess_source, None, "re-armar el mismo bloque desarma"); + } + + fn fake_completion(cands: &[&str], start: usize, end: usize) -> shuma_line::Completion { + shuma_line::Completion { + kind: shuma_line::CompletionKind::Command, + candidates: cands.iter().map(|s| s.to_string()).collect(), + replace_start: start, + replace_end: end, + } + } + + #[test] + fn completion_tab_cycles_then_wraps() { + let mut s = State::new(Source::Local); + s.completion = Some(fake_completion(&["cargo", "cat", "cal"], 0, 0)); + s.completion_index = 0; + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Tab), None))); + assert_eq!(s.completion_index, 1); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Tab), None))); + assert_eq!(s.completion_index, 2); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Tab), None))); + assert_eq!(s.completion_index, 0, "Tab cicla con wrap"); + } + + #[test] + fn completion_arrows_cycle_both_ways() { + let mut s = State::new(Source::Local); + s.completion = Some(fake_completion(&["a", "b", "c"], 0, 0)); + s.completion_index = 0; + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowUp), None))); + assert_eq!(s.completion_index, 2, "↑ desde 0 va al último"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowDown), None))); + assert_eq!(s.completion_index, 0); + } + + #[test] + fn completion_enter_accepts_without_submitting() { + let mut s = State::new(Source::Local); + s.input.set_text("ca"); + s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); + s.completion_index = 1; // "cat" + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!(s.input.text(), "cat", "Enter aplica el resaltado"); + assert!(s.completion.is_none(), "y cierra el popup"); + assert!(!s.is_running(), "Enter con popup NO ejecuta el comando"); + } + + #[test] + fn completion_escape_closes_without_change() { + let mut s = State::new(Source::Local); + s.input.set_text("ca"); + s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Escape), None))); + assert!(s.completion.is_none()); + assert_eq!(s.input.text(), "ca", "Esc no toca el texto"); + } + + #[test] + fn typing_closes_completion_popup() { + let mut s = State::new(Source::Local); + s.input.set_text("ca"); + s.completion = Some(fake_completion(&["cargo", "cat"], 0, 2)); + let key = KeyEvent { + key: Key::Character("r".into()), + state: KeyState::Pressed, + text: Some("r".into()), + modifiers: Modifiers::default(), + repeat: false, + }; + s = update(s, Msg::Key(key)); + assert!(s.completion.is_none(), "tipear cierra el popup"); + assert_eq!(s.input.text(), "car", "y la tecla se procesa normal"); + } + + #[test] + fn toggle_stage_flips_expanded_set() { + let mut s = State::new(Source::Local); + s = update(s, Msg::ToggleStage { block: 2, stage: 0 }); + assert!(s.expanded_stages.contains(&(2, 0)), "primer toggle despliega"); + s = update(s, Msg::ToggleStage { block: 2, stage: 0 }); + assert!( + !s.expanded_stages.contains(&(2, 0)), + "segundo toggle repliega" + ); + } + + #[test] + fn build_spec_tui_prefix_overrides_default() { + // `:tui ls` no es típico, pero el prefix lo fuerza igual. + let (spec, tui) = build_spec(":tui ls", "/"); + assert!(matches!(spec.exec, shuma_exec::Exec::Pty { .. })); + assert!(tui.is_some()); + } + + #[test] + fn key_to_pty_bytes_handles_special_keys() { + let enter = ev(Key::Named(NamedKey::Enter), None); + assert_eq!(key_to_pty_bytes(&enter), b"\r"); + let up = ev(Key::Named(NamedKey::ArrowUp), None); + assert_eq!(key_to_pty_bytes(&up), b"\x1b[A"); + let esc = ev(Key::Named(NamedKey::Escape), None); + assert_eq!(key_to_pty_bytes(&esc), b"\x1b"); + // Ctrl-C → 0x03. + let ctrl_c = KeyEvent { + key: Key::Character("c".into()), + state: KeyState::Pressed, + text: Some("c".into()), + modifiers: Modifiers { + ctrl: true, + ..Default::default() + }, + repeat: false, + }; + assert_eq!(key_to_pty_bytes(&ctrl_c), vec![3u8]); + } + + #[test] + fn source_daemon_failure_surfaces_as_notice() { + // Sin daemon corriendo, start_run con Source::Daemon debe + // dejar un notice rojo y no enredarse — el shell sigue vivo. + let mut s = State::new(Source::Daemon { + socket: Some(PathBuf::from("/tmp/shuma-no-existe-test.sock")), + label: None, + }); + let _ = std::fs::remove_file("/tmp/shuma-no-existe-test.sock"); + s.input.set_text("echo hola"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.starts_with("✘ daemon:"))); + assert!(!s.is_running(), "no debe quedar un run vivo si falló"); + } + + #[test] + fn ampersand_suffix_starts_background_job() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 5 &"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(!s.is_running(), "& no debe dejar un foreground vivo"); + assert_eq!(s.bg_jobs.len(), 1); + // El header de la card del job: `[0] $ sleep 5 &`. + assert!(s + .output + .iter() + .any(|l| l.text.contains("[0]") && l.text.contains("sleep 5"))); + // Cancelar el job así no queda sleep colgado en el host. + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s.input.set_text(":term 0"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s + .output + .iter() + .any(|l| l.text.contains("[0] SIGTERM enviado"))); + } + + #[test] + fn jobs_builtin_lists_background_jobs() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("sleep 5 &"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s.input.set_text(":jobs"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s + .output + .iter() + .any(|l| l.text.contains("[0]") && l.text.contains("sleep"))); + s.input.set_text(":term 0"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + } + + #[test] + fn jobs_builtin_empty_shows_notice() { + let mut s = State::new(Source::Local); + s.input.set_text(":jobs"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!(s.output.iter().any(|l| l.text.contains("sin jobs"))); + } + + #[test] + fn enter_with_open_quote_inserts_newline_instead_of_submit() { + let mut s = State::new(Source::Local); + s.input.set_text("echo 'hola"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + // No debe haber arrancado un run — Enter agregó \n. + assert!(!s.is_running()); + assert_eq!(s.input.text(), "echo 'hola\n"); + } + + #[test] + fn shift_enter_always_inserts_newline() { + let mut s = State::new(Source::Local); + s.input.set_text("ls"); // texto completo, sin continuation pendiente + let shift_enter = KeyEvent { + key: Key::Named(NamedKey::Enter), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers { + shift: true, + ..Default::default() + }, + repeat: false, + }; + s = update(s, Msg::Key(shift_enter)); + assert!(!s.is_running(), "shift+enter no debe ejecutar"); + assert_eq!(s.input.text(), "ls\n"); + } + + #[test] + fn paste_key_event_is_recognized() { + // Ctrl-V con texto en clipboard se procesa como paste (no + // termina llamando apply_key con el carácter 'v'). Sin display + // server (CI), read_clipboard devuelve None y el state no + // cambia. Pero verificamos que la rama de paste se toma. + let mut s = State::new(Source::Local); + s.input.set_text("hola"); + let ctrl_v = KeyEvent { + key: Key::Character("v".into()), + state: KeyState::Pressed, + text: Some("v".into()), + modifiers: Modifiers { + ctrl: true, + ..Default::default() + }, + repeat: false, + }; + s = update(s, Msg::Key(ctrl_v)); + // El input no debe llevar una 'v' al final — la rama paste se + // tragó la tecla (y en CI sin clipboard no insertó nada). + assert_eq!(s.input.text(), "hola"); + } + + #[test] + fn ansi_idx_palette_matches_expected_basics() { + // Idx 0 = negro, 15 = blanco, 196 = rojo claro del cubo. + let black = ansi_idx_to_color(0); + assert_eq!(black.components[0], 0.0); + let white = ansi_idx_to_color(15); + assert!(white.components[0] > 0.99); + } + + #[test] + fn arrow_right_at_end_accepts_ghost() { + let mut s = State::new(Source::Local); + { + let mut h = s.history.lock().unwrap(); + let _ = h.append(shuma_history::Entry::new("cargo build --release", "/", 1)); + } + s.input.set_text("cargo bu"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::ArrowRight), None))); + assert_eq!(s.input.text(), "cargo build --release"); + } + + #[test] + fn partition_line_segments_a_line_with_a_url() { + use shuma_line::{Decoration, DecorationKind}; + let theme = Theme::dark(); + let text = "abrí https://gioser.net y mirá"; + let url_start = text.find("https").unwrap(); + let url_end = url_start + "https://gioser.net".len(); + let decs = vec![Decoration { + start: url_start, + end: url_end, + kind: DecorationKind::Url(text[url_start..url_end].to_string()), + }]; + let pieces = partition_line(text, &decs, theme.fg_text, &theme); + assert_eq!(pieces.len(), 3, "pre, url, post: {pieces:?}"); + assert_eq!(pieces[0].color, theme.fg_text); + assert!(pieces[0].deco.is_none()); + assert_eq!(pieces[1].color, theme.accent); + assert!(matches!(pieces[1].deco, Some(DecorationKind::Url(_)))); + assert_eq!(pieces[2].color, theme.fg_text); + } + + #[test] + fn open_decoration_cd_into_a_directory() { + let mut s = State::new(Source::Local); + let target = std::env::temp_dir(); + let kind = shuma_line::DecorationKind::Path { + abs: target.clone(), + is_dir: true, + is_executable: false, + is_symlink: false, + }; + s = update(s, Msg::OpenDecoration(kind)); + // cwd cambia al directorio target (no comparamos canónico — el + // open_decoration acepta el path tal cual viene si es dir). + assert_eq!(s.cwd, target); + } + + #[test] + fn open_decoration_git_sha_prefills_input() { + let mut s = State::new(Source::Local); + let kind = shuma_line::DecorationKind::GitSha("abcdef0123456".into()); + s = update(s, Msg::OpenDecoration(kind)); + assert_eq!(s.input.text(), "git show abcdef0123456"); + } + + #[test] + fn open_decoration_path_executable_prefills_input() { + let mut s = State::new(Source::Local); + let kind = shuma_line::DecorationKind::Path { + abs: PathBuf::from("/usr/bin/ls"), + is_dir: false, + is_executable: true, + is_symlink: false, + }; + s = update(s, Msg::OpenDecoration(kind)); + assert_eq!(s.input.text(), "/usr/bin/ls"); + } + + #[test] + fn dispatch_maps_clear() { + assert!(matches!(dispatch("shell.clear"), Some(Msg::Clear))); + assert!(matches!(dispatch("shell.cancel"), Some(Msg::Cancel))); + assert!(dispatch("desconocido").is_none()); + } + + #[test] + fn contributions_expose_clear_and_cancel_shortcuts() { + let s = State::new(Source::Local); + let c = contributions(&s); + assert!(c.monitors.is_empty()); + let labels: Vec<&str> = c.shortcuts.iter().map(|s| s.label.as_str()).collect(); + assert!(labels.contains(&"Clear"), "{labels:?}"); + assert!(labels.contains(&"Cancel"), "{labels:?}"); + } + + #[test] + fn typing_appends_to_input() { + let mut s = State::new(Source::Local); + // El widget text-input usa apply_key con KeyEvent que incluye texto. + let key = KeyEvent { + key: Key::Character("h".into()), + state: KeyState::Pressed, + text: Some("h".into()), + modifiers: Modifiers::default(), + repeat: false, + }; + s = update(s, Msg::Key(key)); + assert_eq!(s.input.text(), "h"); + } + + #[test] + fn external_command_records_intention_in_graph() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + assert!(s.intent_graph().is_empty(), "grafo arranca vacío"); + s.input.set_text("echo lienzo"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert_eq!( + s.intent_graph().len(), + 1, + "Enter debe registrar el `%c1` en el grafo" + ); + assert_eq!(s.intent_graph().commands()[0].intention, "echo lienzo"); + s = drain_until_idle(s); + let node = &s.intent_graph().commands()[0]; + assert_eq!(node.status, shuma_intent::NodeStatus::Ok); + assert!( + node.output_bytes >= 7, + "esperaba ≥7 bytes (len de 'lienzo\\n'), recibí {}", + node.output_bytes + ); + } + + #[test] + fn failed_command_records_failed_status() { + let mut s = State::new(Source::Local); + s.cwd = PathBuf::from("/"); + s.input.set_text("false"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + s = drain_until_idle(s); + assert_eq!(s.intent_graph().len(), 1); + assert_eq!( + s.intent_graph().commands()[0].status, + shuma_intent::NodeStatus::Failed + ); + } + + #[test] + fn builtin_does_not_register_in_graph() { + let mut s = State::new(Source::Local); + s.input.set_text("pwd"); + s = update(s, Msg::Key(ev(Key::Named(NamedKey::Enter), None))); + assert!( + s.intent_graph().is_empty(), + "builtins no entran al grafo de intenciones" + ); + } + + #[test] + fn insert_at_cursor_appends_into_input() { + let mut s = State::new(Source::Local); + // `set_text` deja el cursor al final, así que `insert` extiende. + s.input.set_text("sort "); + s = update(s, Msg::InsertAtCursor("%p1".into())); + assert_eq!(s.input.text(), "sort %p1"); + } + + #[test] + fn push_output_groups_lines_into_command_blocks() { + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ ls")); + s.push_output(OutputLine::stdout("a.txt")); + s.push_output(OutputLine::stdout("b.txt")); + s.push_output(OutputLine::notice("✔ exit 0")); + let b = s.output[0].block; + assert!(b > 0, "el prompt debe abrir un bloque > 0"); + assert!( + s.output.iter().all(|l| l.block == b), + "comando + salida + exit comparten bloque: {:?}", + s.output.iter().map(|l| l.block).collect::>() + ); + // Un segundo prompt abre un bloque nuevo y monotónico. + s.push_output(OutputLine::prompt("$ pwd")); + assert!( + s.output.last().unwrap().block > b, + "el segundo comando abre un bloque nuevo" + ); + } + + #[test] + fn push_in_block_keeps_async_output_out_of_foreground_card() { + // El bug de "output mezclado": un job async drenando en su bloque + // NO debe contaminar el bloque del comando de foreground, aunque + // `current_block` apunte a este último. + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ fg")); // abre bloque fg + let fg_block = s.current_block; + let job_block = s.open_block(); // bloque propio del job (current sigue en fg) + s.push_in_block(job_block, OutputLine::stdout("salida del job")); + s.push_output(OutputLine::stdout("salida del fg")); + let bg = s + .output + .iter() + .find(|l| l.text == "salida del job") + .unwrap() + .block; + let fg = s + .output + .iter() + .find(|l| l.text == "salida del fg") + .unwrap() + .block; + assert_eq!(bg, job_block); + assert_eq!(fg, fg_block); + assert_ne!(bg, fg, "job y foreground en cards distintas"); + } + + #[test] + fn scroll_clamps_between_zero_and_overflow() { + let mut s = State::new(Source::Local); + *s.out_overflow.lock().unwrap() = 100.0; + s = update(s, Msg::Scroll(40.0)); + assert_eq!(s.scroll_px, 40.0); + s = update(s, Msg::Scroll(200.0)); // pasa del tope → clamp a overflow + assert_eq!(s.scroll_px, 100.0); + s = update(s, Msg::Scroll(-500.0)); // de vuelta al fondo + assert_eq!(s.scroll_px, 0.0); + } + + #[test] + fn toggle_block_flips_collapsed_set() { + let mut s = State::new(Source::Local); + s = update(s, Msg::ToggleBlock(3)); + assert!(s.collapsed.contains(&3), "primer toggle colapsa"); + s = update(s, Msg::ToggleBlock(3)); + assert!(!s.collapsed.contains(&3), "segundo toggle despliega"); + } + + #[test] + fn clear_output_also_drops_collapsed_set() { + let mut s = State::new(Source::Local); + s.push_output(OutputLine::prompt("$ ls")); + s.collapsed.insert(s.output[0].block); + s.clear_output(); + assert!(s.output.is_empty()); + assert!(s.collapsed.is_empty(), "clear limpia también los colapsos"); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/update.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/update.rs new file mode 100644 index 0000000..702f513 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/update.rs @@ -0,0 +1,1737 @@ +use super::*; + +/// Mapea `action_id` de `ShortcutAction::ModuleAction` al `Msg`. +pub fn dispatch(action_id: &str) -> Option { + match action_id { + "shell.clear" => Some(Msg::Clear), + "shell.cancel" => Some(Msg::Cancel), + _ => None, + } +} + +/// Traduce un `KeyEvent` a una llamada sobre `LineState`. Devuelve +/// `true` si tocó el state. No maneja Enter, Tab, Up/Down ni Ctrl-C +/// (esos los intercepta el `update` del módulo). +pub(crate) fn apply_key_to_line(line: &mut LineState, ev: &KeyEvent) -> bool { + match &ev.key { + Key::Named(NamedKey::Backspace) => { + line.backspace(); + true + } + Key::Named(NamedKey::Delete) => { + line.delete(); + true + } + Key::Named(NamedKey::ArrowLeft) => { + if ev.modifiers.ctrl { + line.move_word_left(); + } else { + line.move_left(); + } + true + } + Key::Named(NamedKey::ArrowRight) => { + if ev.modifiers.ctrl { + line.move_word_right(); + } else { + line.move_right(); + } + true + } + Key::Named(NamedKey::Home) => { + line.move_home(); + true + } + Key::Named(NamedKey::End) => { + line.move_end(); + true + } + Key::Named(NamedKey::Space) => { + line.insert(" "); + true + } + _ => { + if let Some(text) = &ev.text { + if !text.is_empty() && !text.chars().any(|c| c.is_control()) { + line.insert(text); + return true; + } + } + false + } + } +} + +pub fn update(state: State, msg: Msg) -> State { + let mut s = state; + match msg { + Msg::Key(ev) => { + if ev.state != KeyState::Pressed { + return s; + } + // Si hay un TUI activo, las teclas van al stdin del PTY + // (no al input). El usuario sale tipeando dentro del TUI + // (`:q` en vim, `q` en less, etc.). + if is_tui_active(&s) { + // Shift+Insert siempre pega. Ctrl-V también — en TUIs + // tipo less/vim no suele ser un binding (vim usa Ctrl-V + // para visual-block en normal mode; al editar dentro + // de insert mode tampoco). Si choca con un usuario + // específico, en el futuro lo gateamos por allowlist. + let paste = (ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("v"))) + || (ev.modifiers.shift && matches!(&ev.key, Key::Named(NamedKey::Insert))); + if paste { + forward_paste_to_pty(&s); + return s; + } + forward_key_to_pty(&s, &ev); + return s; + } + // Si el overlay de búsqueda está abierto, las teclas van ahí. + if s.history_search.is_some() { + return handle_search_key(s, &ev); + } + // Ctrl-C: si hay run vivo, mandarle SIGTERM y comer la tecla. + if ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("c")) + { + if s.running.is_some() { + return cancel_running(s); + } + } + // Ctrl-V (o Shift+Insert): pega del clipboard al input. + // (Si hay TUI, lo intercepta `is_tui_active` arriba; ese + // camino tiene su propio paste.) + let is_paste = (ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("v"))) + || (ev.modifiers.shift && matches!(&ev.key, Key::Named(NamedKey::Insert))); + if is_paste { + if let Some(text) = read_clipboard() { + s.input.insert(&text); + } + return s; + } + // Ctrl-R: abrir overlay de búsqueda de historial. + if ev.modifiers.ctrl + && matches!(&ev.key, Key::Character(c) if c.eq_ignore_ascii_case("r")) + { + s.history_search = Some(HistorySearch::default()); + return s; + } + // Popup de completado abierto: las teclas lo navegan. + if s.completion.is_some() { + match &ev.key { + // Tab cicla adelante; Shift+Tab atrás. + Key::Named(NamedKey::Tab) => { + return cycle_completion(s, if ev.modifiers.shift { -1 } else { 1 }); + } + Key::Named(NamedKey::ArrowDown) => return cycle_completion(s, 1), + Key::Named(NamedKey::ArrowUp) => return cycle_completion(s, -1), + // Enter o flecha derecha: acepta el resaltado (no ejecuta). + Key::Named(NamedKey::Enter) | Key::Named(NamedKey::ArrowRight) => { + return accept_completion(s); + } + Key::Named(NamedKey::Escape) => { + close_completion(&mut s); + return s; + } + // Cualquier otra tecla cierra el popup y se procesa normal. + _ => close_completion(&mut s), + } + } + // F1..F8: ejecuta el grupo guardado de esa posición (`:save`). + // (F12 lo reserva el chasis para cerrar.) + if let Some(idx) = fkey_index(&ev.key) { + return run_group(s, idx); + } + // Enter: ejecuta — pero si el texto deja una construcción + // abierta (quote, paren, heredoc, `\` final, pipe pendiente), + // insertamos un salto de línea y seguimos editando. + // Shift+Enter fuerza salto de línea siempre. + if let Key::Named(NamedKey::Enter) = ev.key { + let pending = shuma_line::needs_continuation(s.input.text()); + if pending || ev.modifiers.shift { + s.input.insert("\n"); + s.history_cursor = None; + return s; + } + s.history_cursor = None; + s = run_submitted(s); + return s; + } + // Tab: completion. + if let Key::Named(NamedKey::Tab) = ev.key { + return apply_completion_msg(s); + } + // Up/Down: navegación de historial. + if let Key::Named(NamedKey::ArrowUp) = ev.key { + return navigate_history(s, shuma_history::Nav::Older); + } + if let Key::Named(NamedKey::ArrowDown) = ev.key { + return navigate_history(s, shuma_history::Nav::Newer); + } + // Flecha derecha al final de línea con ghost visible: acepta ghost. + if let Key::Named(NamedKey::ArrowRight) = ev.key { + if !ev.modifiers.ctrl && s.input.cursor() == s.input.text().len() { + if let Some(suffix) = current_ghost(&s) { + if !suffix.is_empty() { + s.input.insert(&suffix); + return s; + } + } + } + } + apply_key_to_line(&mut s.input, &ev); + // Cualquier edición rompe el cursor de navegación de historial. + s.history_cursor = None; + } + Msg::FocusInput => { + s.focused = true; + } + Msg::Clear => { + s.clear_output(); + } + Msg::ToggleBlock(id) => { + if !s.collapsed.remove(&id) { + s.collapsed.insert(id); + } + } + Msg::Scroll(delta) => { + // `out_overflow` lo publicó la última `view`; clampa sin que + // el handler tenga que recomputar la geometría. + let overflow = s.out_overflow.lock().map(|g| *g).unwrap_or(0.0); + s.scroll_px = (s.scroll_px + delta).clamp(0.0, overflow); + } + Msg::RunLine(line) => { + s.input.set_text(line); + s = run_submitted(s); + } + Msg::ToggleStage { block, stage } => { + let key = (block, stage); + if !s.expanded_stages.remove(&key) { + s.expanded_stages.insert(key); + } + } + Msg::SetReprocess(block) => { + // Toggle: re-armar el mismo bloque lo desarma. + if s.reprocess_source == Some(block) { + s.reprocess_source = None; + } else { + s.reprocess_source = Some(block); + s.focused = true; + } + } + Msg::RunGroup(idx) => { + s = run_group(s, idx); + } + Msg::Tick => { + s = drain_run(s); + } + Msg::Cancel => { + if s.running.is_some() { + s = cancel_running(s); + } + } + Msg::OpenDecoration(kind) => { + s = open_decoration(s, kind); + } + Msg::InsertAtCursor(text) => { + // Cerramos cualquier overlay activo para que el texto + // pegado quede visible sin tener que cerrar el Ctrl-R a mano. + s.history_search = None; + s.history_cursor = None; + s.input.insert(&text); + s.focused = true; + } + Msg::VimPaste => { + // Sólo aplica si hay un TUI vivo; `forward_paste_to_pty` es + // no-op silencioso si no. + forward_paste_to_pty(&s); + } + Msg::VimDrag { + end, + dx, + dy, + ax, + ay, + } => { + let fresh = s.vim_sel.map_or(true, |v| !v.active); + if fresh { + s.vim_sel = Some(VimSel { + ax, + ay, + hx: ax + dx, + hy: ay + dy, + active: !end, + }); + } else if let Some(v) = s.vim_sel.as_mut() { + v.hx += dx; + v.hy += dy; + if end { + v.active = false; + } + } + if end { + // Umbral mínimo de drag: un click (o jitter sub-celda) no + // selecciona ni copia. Exige cruzar ~una celda para contar. + let dragged = s.vim_sel.is_some_and(|v| { + let (dx, dy) = (v.hx - v.ax, v.hy - v.ay); + (dx * dx + dy * dy).sqrt() >= crate::view::VIM_CHAR_W as f32 + }); + if dragged { + copy_vim_selection(&s); + } else { + s.vim_sel = None; + } + } + } + } + s +} + +/// Acciona el click sobre una decoración del output. Ninguna acción +/// bloquea la UI: `xdg-open` se forkea detached, y los cambios al +/// state (cwd, input) son in-memory. +pub(crate) fn open_decoration(mut s: State, kind: shuma_line::DecorationKind) -> State { + use shuma_line::DecorationKind as Dk; + match kind { + Dk::Path { + abs, + is_dir, + is_executable, + .. + } => { + if is_dir { + // Directorios → cd. Cambia el cwd y lo refleja en el + // header sin "ejecutar" un comando. + if abs.is_dir() { + s.cwd = abs; + s.completion_source = Arc::new(ShellSource::new(&s.cwd)); + } + } else if is_executable { + // Binarios → pre-llenar el input con el path; el + // usuario decide los args y Enter. + s.input.set_text(abs.display().to_string()); + } else { + // Archivos regulares → xdg-open detached. + spawn_detached("xdg-open", &[abs.display().to_string().as_str()]); + } + } + Dk::Url(url) => { + spawn_detached("xdg-open", &[&url]); + } + Dk::GrepRef { abs, line_no, col } => { + // `$EDITOR +line file` para vim/neovim/helix; si no hay + // EDITOR, xdg-open al archivo y listo. + if let Ok(editor) = std::env::var("EDITOR") { + let line_flag = format!("+{line_no}"); + let path = abs.display().to_string(); + let args: Vec<&str> = match col { + Some(_) => vec![&line_flag, &path], + None => vec![&line_flag, &path], + }; + spawn_detached(&editor, &args); + } else { + spawn_detached("xdg-open", &[abs.display().to_string().as_str()]); + } + } + Dk::GitSha(sha) => { + // Pre-llenar `git show ` — la acción más útil 99% del tiempo. + s.input.set_text(format!("git show {sha}")); + } + Dk::IssueRef(_) | Dk::BoxDraw => { + // Sin acción asociada. + } + } + s +} + +/// Lanza un proceso "detached" — no esperamos, no leemos su output, +/// y el padre puede morir sin matarlo (`process_group(0)` para +/// despegarlo de la sesión de shuma). Usado para `xdg-open` y `$EDITOR` +/// disparados desde clicks. +pub(crate) fn spawn_detached(program: &str, args: &[&str]) { + use std::os::unix::process::CommandExt; + let _ = std::process::Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .process_group(0) + .spawn(); +} + +/// Aplica un Tab: +/// - popup abierto: cicla al siguiente candidato (no toca el texto, así el +/// rango de reemplazo del `Completion` guardado sigue válido). +/// - popup cerrado: 0 candidatos → nada; 1 → lo inserta directo; ≥2 → abre +/// el popup con el primero resaltado (sin tocar el texto todavía). +pub(crate) fn apply_completion_msg(mut s: State) -> State { + if let Some(comp) = &s.completion { + let n = comp.candidates.len(); + if n > 0 { + s.completion_index = (s.completion_index + 1) % n; + } + return s; + } + let comp = s.input.complete(s.completion_source.as_ref()); + if comp.is_empty() { + return s; + } + if comp.candidates.len() == 1 { + let candidate = comp.candidates[0].clone(); + s.input.apply_completion(&comp, &candidate); + return s; + } + s.completion = Some(comp); + s.completion_index = 0; + s +} + +/// Cierra el popup de completado sin aplicar nada. +pub(crate) fn close_completion(s: &mut State) { + s.completion = None; + s.completion_index = 0; +} + +/// Cicla el candidato resaltado del popup (`delta` ±1, con wrap). No-op si +/// el popup está cerrado. +pub(crate) fn cycle_completion(mut s: State, delta: i32) -> State { + if let Some(comp) = &s.completion { + let n = comp.candidates.len() as i32; + if n > 0 { + s.completion_index = (s.completion_index as i32 + delta).rem_euclid(n) as usize; + } + } + s +} + +/// Acepta el candidato resaltado del popup, lo inserta y cierra el popup. +pub(crate) fn accept_completion(mut s: State) -> State { + if let Some(comp) = s.completion.take() { + if let Some(candidate) = comp.candidates.get(s.completion_index) { + s.input.apply_completion(&comp, candidate); + } + } + s.completion_index = 0; + s +} + +/// Prefijo común más largo de un slice de strings — usado en completion +/// cuando hay múltiples candidatos. +pub(crate) fn common_prefix(items: &[String]) -> String { + let Some(first) = items.first() else { + return String::new(); + }; + let mut end = first.len(); + for s in &items[1..] { + let bytes = s.as_bytes(); + let fbytes = first.as_bytes(); + let mut i = 0; + while i < end && i < bytes.len() && bytes[i] == fbytes[i] { + i += 1; + } + end = i; + if end == 0 { + break; + } + } + // Asegurarse de cortar en límite de carácter UTF-8. + while end > 0 && !first.is_char_boundary(end) { + end -= 1; + } + first[..end].to_string() +} + +/// Navega el historial por Up/Down. +pub(crate) fn navigate_history(mut s: State, dir: shuma_history::Nav) -> State { + let next = { + let history = s.history.lock().unwrap(); + history + .navigate(s.history_cursor, dir) + .map(|(i, e)| (i, e.line.clone())) + }; + if let Some((i, line)) = next { + s.history_cursor = Some(i); + s.input.set_text(line); + } else if matches!(dir, shuma_history::Nav::Newer) { + // Salir del historial al final: línea vacía. + s.history_cursor = None; + s.input.clear(); + } + s +} + +/// Maneja teclas mientras el overlay Ctrl-R está abierto. +pub(crate) fn handle_search_key(mut s: State, ev: &KeyEvent) -> State { + let Some(mut search) = s.history_search.take() else { + return s; + }; + match &ev.key { + Key::Named(NamedKey::Escape) => { + // Salida sin aceptar. + return s; + } + Key::Named(NamedKey::Enter) => { + // Acepta el seleccionado: pasa a la línea (sin ejecutar). + let pick = { + let history = s.history.lock().unwrap(); + history + .fuzzy_search(&search.query, 50) + .get(search.selected) + .map(|e| e.line.clone()) + }; + if let Some(line) = pick { + s.input.set_text(line); + } + return s; + } + Key::Named(NamedKey::Backspace) => { + search.query.pop(); + search.selected = 0; + } + Key::Named(NamedKey::ArrowDown) => { + let history = s.history.lock().unwrap(); + let max = history.fuzzy_search(&search.query, 50).len(); + if max > 0 && search.selected + 1 < max { + search.selected += 1; + } + } + Key::Named(NamedKey::ArrowUp) => { + search.selected = search.selected.saturating_sub(1); + } + _ => { + if let Some(text) = &ev.text { + if !text.is_empty() && !text.chars().any(|c| c.is_control()) { + search.query.push_str(text); + search.selected = 0; + } + } + } + } + s.history_search = Some(search); + s +} + +/// `true` si hay un `ActiveRun` en modo TUI (PTY + vt100). Las teclas +/// van al stdin del PTY mientras esto sea cierto. +pub(crate) fn is_tui_active(s: &State) -> bool { + let Some(arc) = s.running.as_ref() else { + return false; + }; + let g = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + g.tui.is_some() +} + +/// Traduce una tecla a su secuencia de bytes para el PTY (xterm-compat). +/// Las TUIs esperan estos códigos. +pub(crate) fn key_to_pty_bytes(ev: &KeyEvent) -> Vec { + match &ev.key { + Key::Named(NamedKey::Enter) => b"\r".to_vec(), + Key::Named(NamedKey::Tab) => b"\t".to_vec(), + Key::Named(NamedKey::Backspace) => b"\x7f".to_vec(), + Key::Named(NamedKey::Escape) => b"\x1b".to_vec(), + Key::Named(NamedKey::ArrowUp) => b"\x1b[A".to_vec(), + Key::Named(NamedKey::ArrowDown) => b"\x1b[B".to_vec(), + Key::Named(NamedKey::ArrowRight) => b"\x1b[C".to_vec(), + Key::Named(NamedKey::ArrowLeft) => b"\x1b[D".to_vec(), + Key::Named(NamedKey::Home) => b"\x1b[H".to_vec(), + Key::Named(NamedKey::End) => b"\x1b[F".to_vec(), + Key::Named(NamedKey::PageUp) => b"\x1b[5~".to_vec(), + Key::Named(NamedKey::PageDown) => b"\x1b[6~".to_vec(), + Key::Named(NamedKey::Delete) => b"\x1b[3~".to_vec(), + Key::Named(NamedKey::Space) => b" ".to_vec(), + _ => { + // Ctrl-: codifica el byte 0x01..0x1a para letras. + if ev.modifiers.ctrl { + if let Key::Character(c) = &ev.key { + if let Some(ch) = c.chars().next() { + let lo = ch.to_ascii_lowercase(); + if ('a'..='z').contains(&lo) { + return vec![(lo as u8) - b'a' + 1]; + } + } + } + } + ev.text.as_deref().unwrap_or("").as_bytes().to_vec() + } + } +} + +/// Lee el clipboard del SO (vía `arboard`). Devuelve `None` si no hay +/// display server, está vacío, o el contenido no es texto. No cachea — +/// el sistema tiene su propio TTL. +pub(crate) fn read_clipboard() -> Option { + let mut clip = arboard::Clipboard::new().ok()?; + clip.get_text().ok() +} + +/// Escribe texto al clipboard del SO. No-op silencioso sin display server. +pub(crate) fn set_clipboard(text: &str) { + if let Ok(mut clip) = arboard::Clipboard::new() { + let _ = clip.set_text(text.to_string()); + } +} + +/// Extrae el texto de la selección del card de vim sobre el screen +/// actual del PTY y lo copia al clipboard. Selección lineal por filas +/// (estilo terminal), cada fila recortada de espacios al final. +pub(crate) fn copy_vim_selection(s: &State) { + let Some(vs) = s.vim_sel else { return }; + let Some(arc) = s.running.as_ref() else { + return; + }; + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let Some(tui) = guard.tui.as_ref() else { + return; + }; + let screen = tui.parser.screen(); + let (rows, cols) = screen.size(); + let mut grid: Vec> = Vec::with_capacity(rows as usize); + for r in 0..rows { + let mut line: Vec = Vec::with_capacity(cols as usize); + for c in 0..cols { + let ch = match screen.cell(r, c) { + Some(cell) if cell.has_contents() => cell.contents().chars().next().unwrap_or(' '), + _ => ' ', + }; + line.push(ch); + } + grid.push(line); + } + let (cw, lh) = match s.vim_metrics.lock() { + Ok(g) if g.0 > 1.0 && g.1 > 1.0 => (g.0 as f64, g.1 as f64), + _ => (crate::view::VIM_CHAR_W, crate::view::VIM_LINE_H), + }; + let (r0, c0) = crate::view::vim_px_to_cell(vs.ax as f64, vs.ay as f64, cw, lh); + let (r1, c1) = crate::view::vim_px_to_cell(vs.hx as f64, vs.hy as f64, cw, lh); + let (sr, sc, er, ec) = if (r0, c0) <= (r1, c1) { + (r0, c0, r1, c1) + } else { + (r1, c1, r0, c0) + }; + if sr >= grid.len() { + return; + } + let er = er.min(grid.len() - 1); + let mut out = String::new(); + for r in sr..=er { + let line = &grid[r]; + let lo = if r == sr { sc.min(line.len()) } else { 0 }; + let hi = if r == er { + (ec + 1).min(line.len()) + } else { + line.len() + }; + if hi > lo { + let seg: String = line[lo..hi].iter().collect(); + out.push_str(seg.trim_end()); + } + if r != er { + out.push('\n'); + } + } + if !out.trim().is_empty() { + set_clipboard(&out); + } +} + +/// Pega el contenido del clipboard en el PTY del run activo. Si el TUI +/// hijo está en bracketed-paste mode (DECSET 2004), envuelve la +/// secuencia en `\x1b[200~...\x1b[201~` para que vim, less y emacs +/// distingan "tipeé esto" de "pegué esto" (auto-indent, paste-mode, +/// etc.). No-op silencioso si no hay TUI o el clipboard está vacío. +pub(crate) fn forward_paste_to_pty(s: &State) { + let Some(arc) = s.running.as_ref() else { + return; + }; + let Some(text) = read_clipboard() else { + return; + }; + if text.is_empty() { + return; + } + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let bracketed = guard + .tui + .as_ref() + .map(|t| t.parser.screen().bracketed_paste()) + .unwrap_or(false); + let payload: Vec = if bracketed { + let mut buf: Vec = b"\x1b[200~".to_vec(); + buf.extend_from_slice(text.as_bytes()); + buf.extend_from_slice(b"\x1b[201~"); + buf + } else { + text.into_bytes() + }; + guard.handle.write_input(payload); +} + +/// Manda los bytes de la tecla al PTY del run activo. No-op si no hay +/// tui activo. +pub(crate) fn forward_key_to_pty(s: &State, ev: &KeyEvent) { + let Some(arc) = s.running.as_ref() else { + return; + }; + let bytes = key_to_pty_bytes(ev); + if bytes.is_empty() { + return; + } + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + guard.handle.write_input(bytes); +} + +/// Rama de git activa para `cwd` — `None` si no estamos en un repo (o si +/// HEAD está detached). Implementación minimalista por archivo: sube por +/// los padres buscando `.git`, lee `HEAD` y extrae `refs/heads/`. No +/// usa libgit2 ni lanza procesos (barato de llamar por frame). +pub(crate) fn git_branch(cwd: &std::path::Path) -> Option { + let mut dir = cwd.to_path_buf(); + let git_dir = loop { + let candidate = dir.join(".git"); + if candidate.exists() { + break candidate; + } + if !dir.pop() { + return None; + } + }; + // `.git` puede ser un archivo (worktrees/submódulos) con `gitdir: …`, + // o un directorio con `HEAD` dentro. + let head_path = if git_dir.is_file() { + let s = std::fs::read_to_string(&git_dir).ok()?; + let target = s.strip_prefix("gitdir:")?.trim(); + std::path::PathBuf::from(target).join("HEAD") + } else { + git_dir.join("HEAD") + }; + let head = std::fs::read_to_string(head_path).ok()?; + head.trim() + .strip_prefix("ref: refs/heads/") + .map(|b| b.to_string()) +} + +/// Marcadores de proyecto: archivos/dirs que identifican la "forma" de un +/// directorio. Gatean la predicción por estructura (no sugerir `cargo` sin +/// `Cargo.toml`). +const PROJECT_MARKERS: &[&str] = &[ + ".git", + "Cargo.toml", + "package.json", + "go.mod", + "Makefile", + "pyproject.toml", + "pom.xml", + "build.gradle", +]; + +/// Marcadores de proyecto presentes en `dir`. +fn markers_in(dir: &str) -> Vec { + let base = std::path::Path::new(dir); + PROJECT_MARKERS + .iter() + .filter(|m| base.join(m).exists()) + .map(|m| m.to_string()) + .collect() +} + +/// Construye los `CommandRecord` de `shuma-infer` a partir del historial +/// (éxito = exit 0). +fn infer_records(s: &State) -> Vec { + let Ok(history) = s.history.lock() else { + return Vec::new(); + }; + history + .entries() + .iter() + // El historial Llimphi aún no graba el exit (siempre `None`): + // tratamos lo desconocido como éxito para no descartar todo el + // corpus. Si más adelante se registra el exit, los fallos + // (`Some(c!=0)`) quedan excluidos automáticamente. + .map(|e| { + let ok = e.exit.map_or(true, |c| c == 0); + shuma_infer::CommandRecord::parse(&e.line, e.cwd.clone(), ok) + }) + .collect() +} + +/// Recalcula los patrones emergentes del historial y los cachea en el +/// state. Se llama al cerrar cada comando (cuando el historial creció). +pub(crate) fn refresh_patterns(s: &mut State) { + let records = infer_records(s); + s.patterns = shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default()); +} + +/// Condición de disparo de un patrón: los marcadores de proyecto comunes a +/// todos los directorios donde corrió. +fn pattern_trigger(p: &shuma_infer::EmergingPattern) -> Vec { + let mut dirs = p.directories.iter(); + let Some(first) = dirs.next() else { + return Vec::new(); + }; + let mut common = markers_in(first); + for d in dirs { + let here = markers_in(d); + common.retain(|m| here.contains(m)); + } + common +} + +/// La secuencia que el motor predice como continuación de la sesión, si la +/// hay y el cwd comparte la forma del patrón. +pub(crate) fn predicted_sequence(s: &State) -> Option { + if s.patterns.is_empty() { + return None; + } + let records = infer_records(s); + let tail = &records[records.len().saturating_sub(6)..]; + let (pi, next) = shuma_infer::predict_next(tail, &s.patterns)?; + if next.is_empty() { + return None; + } + // Disparo por estructura: no anticipar un patrón en un directorio que + // no comparte su forma (no sugerir `cargo` sin `Cargo.toml`). + let trigger = pattern_trigger(&s.patterns[pi]); + if !trigger.is_empty() { + let here = markers_in(&s.cwd.to_string_lossy()); + if !trigger.iter().all(|m| here.contains(m)) { + return None; + } + } + Some(next.join(" && ")) +} + +/// Sugerencia "ghost" para la línea actual — la secuencia predicha por el +/// motor de patrones (si aplica) y, tras ella, el prefijo histórico más +/// reciente que extiende el texto que ya está tipeado. +pub(crate) fn current_ghost(s: &State) -> Option { + let text = s.input.text(); + if text.is_empty() || s.input.cursor() != text.len() { + return None; + } + // Corpus por prioridad: secuencia predicha primero, luego historial. + let mut corpus: Vec = Vec::new(); + if let Some(seq) = predicted_sequence(s) { + corpus.push(seq); + } + if let Ok(history) = s.history.lock() { + corpus.extend(history.entries().iter().rev().map(|e| e.line.clone())); + } + shuma_line::ghost_suggestion(text, &corpus) +} + +pub(crate) fn run_submitted(mut s: State) -> State { + let line = s.input.text().to_string(); + let trimmed = line.trim().to_string(); + s.input.clear(); + if trimmed.is_empty() { + return s; + } + s.push_output(OutputLine::prompt(format!("$ {trimmed}"))); + + // Append al historial — todo lo que el usuario Enter-eó queda + // registrado, builtins incluidos (para que `cd ../foo` reaparezca + // por Up). `IgnoreConsecutive` evita ráfagas iguales. + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let entry = shuma_history::Entry::new(trimmed.clone(), s.cwd.display().to_string(), now); + if let Ok(mut h) = s.history.lock() { + let _ = h.append(entry); + } + } + // Recalcula los patrones emergentes con el historial ya actualizado — + // alimentan la predicción del ghost para el próximo comando. + refresh_patterns(&mut s); + + // Builtins primero — no spawnean proceso, corren aunque haya run vivo. + if let Some((cmd, rest)) = split_first_word(&trimmed) { + match cmd { + "cd" => { + return apply_cd(s, rest); + } + "pwd" => { + let cwd_str = s.cwd.display().to_string(); + s.push_output(OutputLine::stdout(cwd_str)); + return s; + } + "clear" => { + s.clear_output(); + return s; + } + "exit" => { + s.push_output(OutputLine::notice( + "exit: el chasis maneja la salida (F12 para cerrar)", + )); + return s; + } + ":jobs" => return apply_jobs_list(s), + ":term" => return apply_jobs_signal(s, rest, JobSignal::Term), + ":stop" => return apply_jobs_signal(s, rest, JobSignal::Stop), + ":cont" => return apply_jobs_signal(s, rest, JobSignal::Cont), + ":limit" => return apply_capture_limit(s, rest), + ":spill" => return apply_spill(s, rest), + ":save" => return save_group(s, rest), + ":groups" => return apply_groups_list(s), + _ => {} + } + } + + // Sufijo `&` (con espacios opcionales antes) → background. El + // background siempre arranca, sin encolar; no hay límite. + if let Some(stripped) = trimmed.strip_suffix('&') { + let cmd = stripped.trim_end().to_string(); + if cmd.is_empty() { + return s; + } + return start_bg(s, cmd); + } + + // Comando externo foreground. Si ya hay uno corriendo, lo encolamos; + // si no, arrancamos ahora mismo. + if s.running.is_some() { + s.queue.push_back(trimmed); + s.push_output(OutputLine::notice( + "⌛ en cola — esperando a que el comando actual termine", + )); + return s; + } + start_run(s, trimmed) +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum JobSignal { + Term, + Stop, + Cont, +} + +/// Lista los bg_jobs con su índice y comando. Marca finalizados. +pub(crate) fn apply_jobs_list(mut s: State) -> State { + if s.bg_jobs.is_empty() { + s.push_output(OutputLine::notice("(sin jobs en background)")); + return s; + } + // Snapshot de los Arc para no retener el borrow de `s.bg_jobs` + // mientras `push_output` toma `&mut s`. + let jobs = s.bg_jobs.clone(); + for (i, arc) in jobs.iter().enumerate() { + let (cmd, status) = match arc.lock() { + Ok(g) => ( + g.command.clone(), + if g.handle.is_finished() { + "done" + } else { + "running" + }, + ), + Err(p) => { + let g = p.into_inner(); + ( + g.command.clone(), + if g.handle.is_finished() { + "done" + } else { + "running" + }, + ) + } + }; + s.push_output(OutputLine::notice(format!("[{i}] {status} {cmd}"))); + } + s +} + +/// Aplica `:term N` / `:stop N` / `:cont N` al job de índice `N`. +/// Stop/Cont son no-op en jobs sin `Killer` (remotos vía daemon). +pub(crate) fn apply_jobs_signal(mut s: State, rest: &str, sig: JobSignal) -> State { + let idx: usize = match rest.trim().parse() { + Ok(n) => n, + Err(_) => { + s.push_output(OutputLine::notice("uso: :term N | :stop N | :cont N")); + return s; + } + }; + let Some(arc) = s.bg_jobs.get(idx).cloned() else { + s.push_output(OutputLine::notice(format!("no hay job [{idx}]"))); + return s; + }; + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let acted = match sig { + JobSignal::Term => match guard.killer.as_ref() { + Some(k) => { + k.term(); + true + } + None => { + // Remoto: cancel via stream close. + guard.handle.kill(); + true + } + }, + JobSignal::Stop => guard.killer.as_ref().map(|k| k.stop()).unwrap_or(false), + JobSignal::Cont => guard.killer.as_ref().map(|k| k.cont()).unwrap_or(false), + }; + let label = match sig { + JobSignal::Term => "TERM", + JobSignal::Stop => "STOP", + JobSignal::Cont => "CONT", + }; + drop(guard); + s.push_output(OutputLine::notice(if acted { + format!("[{idx}] SIG{label} enviado") + } else { + format!("[{idx}] no se pudo enviar SIG{label}") + })); + s +} + +/// `:limit ` — tope de captura de stdout por run. `0` = sin tope. +pub(crate) fn apply_capture_limit(mut s: State, rest: &str) -> State { + match rest.trim().parse::() { + Ok(mb) => { + s.capture_limit_bytes = mb.saturating_mul(1024 * 1024); + let msg = if mb == 0 { + "captura sin tope".to_string() + } else { + format!("captura limitada a {mb} MB por comando") + }; + s.push_output(OutputLine::notice(msg)); + } + Err(_) => s.push_output(OutputLine::notice("uso: :limit (0 = sin tope)")), + } + s +} + +/// `:spill on|off` — volcar a disco la salida que excede el `:limit`. +pub(crate) fn apply_spill(mut s: State, rest: &str) -> State { + let arg = rest.trim(); + let on = matches!(arg, "on" | "si" | "sí" | "1" | "true"); + let off = matches!(arg, "off" | "no" | "0" | "false"); + if !on && !off { + s.push_output(OutputLine::notice("uso: :spill on|off")); + return s; + } + s.spill = on; + let note = match (on, s.capture_limit_bytes) { + (true, 0) => "spill activado — pero sin `:limit ` no tiene efecto", + (true, _) => "spill activado — la salida excedente se vuelca a disco", + (false, _) => "spill desactivado", + }; + s.push_output(OutputLine::notice(note)); + s +} + +/// `:save ` — guarda como grupo los comandos del historial desde el +/// último `:save` (excluyendo los meta-comandos `:`). Ejecutables por F1..F8. +pub(crate) fn save_group(mut s: State, rest: &str) -> State { + let name = rest.trim().to_string(); + if name.is_empty() { + s.push_output(OutputLine::notice( + "uso: :save (agrupa los comandos desde el último :save)", + )); + return s; + } + let (lines, hist_len) = { + let Ok(h) = s.history.lock() else { + return s; + }; + let entries = h.entries(); + // El propio `:save` ya entró al historial: lo excluimos junto con el + // resto de meta-comandos `:`. + let upto = entries.len().saturating_sub(1); + let lines: Vec = entries + .get(s.group_anchor..upto) + .unwrap_or(&[]) + .iter() + .map(|e| e.line.clone()) + .filter(|l| !l.trim_start().starts_with(':')) + .collect(); + (lines, entries.len()) + }; + if lines.is_empty() { + s.push_output(OutputLine::notice( + "nada que guardar — corré algún comando antes de `:save`", + )); + return s; + } + // El próximo grupo arranca desde acá. + s.group_anchor = hist_len; + // Reemplaza un grupo homónimo, si existe. + let n = lines.len(); + if let Some(g) = s.groups.iter_mut().find(|g| g.name == name) { + g.lines = lines; + } else { + s.groups.push(CommandGroup { name: name.clone(), lines }); + } + let fkey = s + .groups + .iter() + .position(|g| g.name == name) + .map(|i| i + 1) + .unwrap_or(0); + s.push_output(OutputLine::notice(format!( + "grupo «{name}» guardado ({n} comandos) — F{fkey} lo ejecuta" + ))); + s +} + +/// `:groups` — lista los grupos guardados con su tecla de función. +pub(crate) fn apply_groups_list(mut s: State) -> State { + if s.groups.is_empty() { + s.push_output(OutputLine::notice( + "(sin grupos — `:save ` guarda los últimos comandos)", + )); + return s; + } + let rows: Vec = s + .groups + .iter() + .enumerate() + .map(|(i, g)| format!("F{} {} ({} cmds)", i + 1, g.name, g.lines.len())) + .collect(); + for r in rows { + s.push_output(OutputLine::notice(r)); + } + s +} + +/// Reconstruye el stdout de un bloque (su card) uniendo las líneas +/// `Stdout` sin etapa — para alimentarlo como stdin de un reprocess. +pub(crate) fn gather_block_stdout(s: &State, block: u64) -> String { + let mut out = String::new(); + for l in &s.output { + if l.block == block && l.kind == OutputKind::Stdout && l.stage.is_none() { + out.push_str(&l.text); + out.push('\n'); + } + } + out +} + +/// Índice de grupo (0-based) para F1..F8; `None` para cualquier otra tecla. +pub(crate) fn fkey_index(key: &Key) -> Option { + match key { + Key::Named(NamedKey::F1) => Some(0), + Key::Named(NamedKey::F2) => Some(1), + Key::Named(NamedKey::F3) => Some(2), + Key::Named(NamedKey::F4) => Some(3), + Key::Named(NamedKey::F5) => Some(4), + Key::Named(NamedKey::F6) => Some(5), + Key::Named(NamedKey::F7) => Some(6), + Key::Named(NamedKey::F8) => Some(7), + _ => None, + } +} + +/// Ejecuta el grupo de índice `idx` (0-based) como una sola línea +/// (`l1 && l2 && …`). No-op si no existe ese grupo. +pub(crate) fn run_group(s: State, idx: usize) -> State { + let Some(joined) = s + .groups + .get(idx) + .map(|g| g.lines.join(" && ")) + .filter(|j| !j.is_empty()) + else { + return s; + }; + let mut s = s; + s.input.set_text(joined); + run_submitted(s) +} + +/// Variante de `start_run` que arranca como job background. La salida +/// se mergea al output buffer prefijada por `[N]`. Devuelve `s` con el +/// nuevo job en `bg_jobs`. +pub(crate) fn start_bg(mut s: State, line: String) -> State { + let cwd_str = s.cwd.display().to_string(); + let (spec, _tui) = build_spec(&line, &cwd_str); + // Background no soporta TUI (no le pintamos el grid; el panel + // sería robado al foreground). Si la línea era TUI, la corremos + // sin PTY igual — el binario podrá quejarse, pero al menos no + // tira la UI. + let bg_spec = if matches!(spec.exec, Exec::Pty { .. }) { + let mut s2 = spec.clone(); + s2.exec = Exec::Shell { + line: line.clone(), + program: "bash".into(), + }; + s2 + } else { + spec + }; + let handle = shuma_exec::run(&bg_spec); + let killer = handle.killer(); + let idx = s.bg_jobs.len(); + // Cada job de fondo vive en SU propia card (bloque propio). Sin esto + // su salida se intercalaba en la card del comando de foreground. + let bg_block = s.open_block(); + s.push_in_block(bg_block, OutputLine::prompt(format!("[{idx}] $ {line} &"))); + let active = ActiveRun { + handle: BackendHandle::Local(handle), + killer: Some(killer), + command: line, + tui: None, + block: bg_block, + }; + s.bg_jobs.push(Arc::new(Mutex::new(active))); + s +} + +pub(crate) fn start_run(mut s: State, line: String) -> State { + let cwd_str = s.cwd.display().to_string(); + let (mut spec, tui) = build_spec(&line, &cwd_str); + // Config de captura y reprocess sólo aplican a runs no-PTY (los TUI + // capturan a vt100, no a buffer, y no consumen stdin reprocesado). + if tui.is_none() { + spec.capture_limit = s.capture_limit_bytes; + spec.spill_path = (s.spill && s.capture_limit_bytes > 0).then(|| { + std::env::temp_dir().join(format!( + "shuma-spill-{}-{}.log", + std::process::id(), + s.current_block + )) + }); + // Reprocess armado: el stdout del bloque fuente alimenta el stdin. + if let Some(src) = s.reprocess_source.take() { + let data = gather_block_stdout(&s, src); + if !data.is_empty() { + spec.stdin_data = Some(data); + } + } + } else { + // Un run TUI desarma cualquier reprocess pendiente (no aplica). + s.reprocess_source = None; + } + // Registramos la intención antes de hacer spawn — si el spawn + // remoto falla, igual queda el nodo `%cN` con status `Failed` + // marcado más abajo (vía el RunEvent::Failed que retorna el + // backend). El lienzo refleja el intento. + s.current_run_node = Some(s.intent_graph.record(line.clone())); + s.current_run_bytes = 0; + // El prompt de este run ya abrió su bloque (current_block); fijamos + // que TODA su salida —drenada en ticks futuros— vaya a esa card. + let run_block = s.current_block; + let active = match &s.source { + Source::Local => { + // Camino histórico — exec directo sobre esta máquina. + let handle = shuma_exec::run(&spec); + let killer = handle.killer(); + ActiveRun { + handle: BackendHandle::Local(handle), + killer: Some(killer), + command: line, + tui, + block: run_block, + } + } + Source::Daemon { socket, .. } => { + let sock = socket + .clone() + .unwrap_or_else(shuma_protocol::default_socket_path); + // PTY remoto full-duplex: conservamos la `TuiSession` para + // pintar el terminal localmente; las teclas/resize viajan al + // daemon por el asa remota. + if tui.is_some() { + match shuma_remote_exec::run_pty(&spec, &sock) { + Ok(h) => ActiveRun { + handle: BackendHandle::Remote(h), + killer: None, + command: line, + tui, + block: run_block, + }, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ daemon pty: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } else { + match shuma_remote_exec::run(&spec, &sock) { + Ok(h) => ActiveRun { + handle: BackendHandle::Remote(h), + killer: None, + command: line, + tui: None, + block: run_block, + }, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ daemon: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } + } + Source::DaemonTcp { + addr, + server_pub_hex, + .. + } => { + // Identidad y pubkey del server hacen falta en ambos caminos + // (PTY y no-PTY); las resolvemos una vez antes de ramificar. + let kp = match load_or_create_identity() { + Ok(kp) => kp, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ identity: {e}"))); + fail_pending_intent(&mut s); + return s; + } + }; + let server_pub = match parse_pub_hex(server_pub_hex) { + Ok(p) => p, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ server_pub_hex: {e}"))); + fail_pending_intent(&mut s); + return s; + } + }; + if tui.is_some() { + match shuma_remote_exec::run_pty_tcp(&spec, addr, kp, server_pub) { + Ok(h) => ActiveRun { + handle: BackendHandle::Remote(h), + killer: None, + command: line, + tui, + block: run_block, + }, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ daemon tcp pty: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } else { + match shuma_remote_exec::run_tcp(&spec, addr, kp, server_pub) { + Ok(h) => ActiveRun { + handle: BackendHandle::Remote(h), + killer: None, + command: line, + tui: None, + block: run_block, + }, + Err(e) => { + s.push_output(OutputLine::notice(format!("✘ daemon tcp: {e}"))); + fail_pending_intent(&mut s); + return s; + } + } + } + } + Source::Remote { .. } => { + // SSH (matilda usa esta variante para otra cosa). El shell + // no tiene un transporte SSH para comandos arbitrarios aún; + // fallback a local con notice claro. + s.push_output(OutputLine::notice( + "shell vía SSH no implementado todavía — corro local", + )); + let handle = shuma_exec::run(&spec); + let killer = handle.killer(); + ActiveRun { + handle: BackendHandle::Local(handle), + killer: Some(killer), + command: line, + tui, + block: run_block, + } + } + }; + s.running = Some(Arc::new(Mutex::new(active))); + s +} + +/// Cierra el nodo `%cN` registrado por `start_run` como fallido cuando +/// el spawn no llega a colocar el `RunHandle` (errores de socket/identity/ +/// pub-hex/tcp). Sin esto el lienzo mostraría el comando como "running" +/// para siempre. Limpiá también el contador de bytes. +pub(crate) fn fail_pending_intent(s: &mut State) { + if let Some(id) = s.current_run_node.take() { + s.intent_graph.complete(id, false, 0); + } + s.current_run_bytes = 0; +} + +/// Carga el `Keypair` del shell desde el archivo de identidad, +/// creando uno nuevo si no existe. Usa el path por defecto de +/// `shuma-link::Keypair::default_path()` (`~/.config/shuma/keys/identity`). +pub(crate) fn load_or_create_identity() -> Result { + let path = shuma_link::Keypair::default_path() + .ok_or_else(|| "no se pudo derivar el path de identidad".to_string())?; + shuma_link::Keypair::load_or_generate(&path).map_err(|e| e.to_string()) +} + +pub(crate) fn parse_pub_hex(hex_str: &str) -> Result { + shuma_link::PublicKey::from_hex(hex_str).map_err(|e| e.to_string()) +} + +/// Si `line` es un pipe «simple» de ≥2 etapas —sólo `Command`/`Argument`/ +/// `Flag`/`Pipe`/espacio, sin comillas, variables, redirecciones, +/// operadores, globs (`* ? [ ] { }`) ni `~`— devuelve sus etapas como +/// [`StageSpec`] para correrlo por `Exec::Direct`. Si no, `None` (cae a +/// `sh -c`, que sí absorbe esa sintaxis). Un único comando también cae a +/// `sh -c`: el modo directo sólo aporta cuando hay tubería que interceptar. +/// +/// Conservador a propósito: `shuma_line::Stage` no recoge los `StringLit` +/// en `args`, así que un pipe con comillas debe ir al shell o perdería el +/// argumento citado. +pub(crate) fn simple_pipe_stages(line: &str) -> Option> { + use shuma_line::TokenKind::*; + let tokens = shuma_line::tokenize(line, shuma_line::Dialect::Bash); + let simple = !tokens.is_empty() + && tokens.iter().all(|t| { + matches!(t.kind, Command | Argument | Flag | Pipe | Whitespace) + && !t.text.contains(['*', '?', '[', ']', '{', '}']) + && !t.text.starts_with('~') + }); + if !simple { + return None; + } + let pipeline = shuma_line::split_pipeline(&tokens); + if pipeline.stages.len() < 2 { + return None; + } + let mut stages = Vec::with_capacity(pipeline.stages.len()); + for st in &pipeline.stages { + // Una etapa sin comando (línea incompleta, p. ej. termina en `|`) + // → al shell, que reporta el error de sintaxis como toca. + let program = st.command.clone()?; + stages.push(StageSpec { + program, + args: st.args.clone(), + }); + } + Some(stages) +} + +/// Decide cómo lanzar `line`: si el primer token está en la allowlist +/// TUI (o el usuario lo prefijó con `:tui`), abre un PTY; si es un pipe +/// simple, lo corre directo con captura por etapa; si no, va por el shell +/// normal (streaming Stdout/Stderr). +pub(crate) fn build_spec(line: &str, cwd: &str) -> (CommandSpec, Option) { + // Prefijo explícito `:tui `. + let (cmd_line, force_tui) = match line.strip_prefix(":tui ") { + Some(rest) => (rest.trim(), true), + None => (line, false), + }; + let first_word = cmd_line.split_whitespace().next().unwrap_or(""); + let is_tui = force_tui || TUI_ALLOWLIST.contains(&first_word); + if !is_tui { + // Pipe «simple» (sólo comandos/args/flags y `|`, sin comillas, + // variables, redirecciones, globs ni `~`): lo corremos directo + // —conectando los procesos nosotros— y activamos la captura por + // etapa (tee) para inspeccionar los intermedios en vivo. Cualquier + // sintaxis que el modo directo no absorbe cae a `sh -c`. + if let Some(stages) = simple_pipe_stages(line) { + return ( + CommandSpec { + exec: Exec::Direct { stages }, + cwd: cwd.to_string(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: true, + }, + None, + ); + } + return (CommandSpec::shell(line, cwd), None); + } + // Bajo PTY: parseamos en stages básicos por whitespace. No soporta + // pipes ni redirecciones — un TUI fullscreen no los usa. + let parts: Vec = cmd_line.split_whitespace().map(String::from).collect(); + if parts.is_empty() { + return (CommandSpec::shell(line, cwd), None); + } + let program = parts[0].clone(); + let args = parts[1..].to_vec(); + let spec = CommandSpec { + exec: Exec::Pty { + program, + args, + cols: PTY_COLS, + rows: PTY_ROWS, + }, + cwd: cwd.to_string(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + }; + // Stage marker — usamos `parts` para sintaxis, no para ejecutar; el + // Exec::Pty arma el spawn directo. La conversión a `StageSpec` + // queda como guía visual del tooltip si después la queremos + // exponer (hoy `Exec::Pty` no usa stages). + let _ = StageSpec { + program: parts[0].clone(), + args: parts[1..].to_vec(), + }; + // `program` ya se movió al `Exec::Pty`; usamos `parts[0]` (sigue vivo). + (spec, Some(TuiSession::new(&parts[0], PTY_ROWS, PTY_COLS))) +} + +pub(crate) fn drain_run(mut s: State) -> State { + let Some(active_arc) = s.running.clone() else { + return s; + }; + let mut finished_with: Option = None; + // Bloque de ESTE run — toda su salida va a su card, aunque el usuario + // haya tipeado otros comandos (que movieron `current_block`) mientras + // corría. + let run_block; + { + let mut guard = match active_arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + run_block = guard.block; + // Resize del PTY si el rect del panel cambió desde el último + // tick. Cell size aproximado: 7.5 px ancho × 16 px alto (12 pt + // monoespacio en Llimphi default). Si el panel se redimensiona + // el TUI hace SIGWINCH al child. + let want_resize: Option<(u16, u16)> = if let Some(tui) = guard.tui.as_ref() { + let (w, h) = match s.last_tui_rect.lock() { + Ok(g) => *g, + Err(p) => *p.into_inner(), + }; + if w > 1.0 && h > 1.0 { + let cols = ((w / 7.5).floor() as i32).clamp(20, 400) as u16; + let rows = ((h / 16.0).floor() as i32).clamp(5, 200) as u16; + if rows != tui.rows || cols != tui.cols { + Some((rows, cols)) + } else { + None + } + } else { + None + } + } else { + None + }; + if let Some((rows, cols)) = want_resize { + guard.handle.resize(rows, cols); + if let Some(tui) = guard.tui.as_mut() { + tui.set_size(rows, cols); + } + } + let events = guard.handle.try_events(); + for ev in events { + match ev { + RunEvent::Stdout(line) => { + // +1 por el `\n` implícito de cada línea drenada. + s.current_run_bytes = s.current_run_bytes.saturating_add(line.len() as u64 + 1); + s.push_in_block(run_block, OutputLine::stdout(line)); + } + RunEvent::StageStdout { stage, line } => { + // Salida de una etapa intermedia (tee). NO suma a + // `current_run_bytes` (el grafo cuenta la salida final); + // queda guardada para el desplegable de su etapa. + s.push_in_block(run_block, OutputLine::stage_stdout(stage, line)); + } + RunEvent::Stderr(line) => { + s.current_run_bytes = s.current_run_bytes.saturating_add(line.len() as u64 + 1); + s.push_in_block(run_block, OutputLine::stderr(line)); + } + RunEvent::Truncated => s.push_in_block( + run_block, + OutputLine::notice("… (salida truncada por límite de captura)"), + ), + RunEvent::Spilled(path) => s.push_in_block( + run_block, + OutputLine::notice(format!("… (resto volcado a {path})")), + ), + RunEvent::Bytes(bytes) => { + s.current_run_bytes = s.current_run_bytes.saturating_add(bytes.len() as u64); + if let Some(tui) = guard.tui.as_mut() { + tui.parser.process(&bytes); + } + } + ev @ (RunEvent::Exited(_) | RunEvent::Failed(_)) => { + finished_with = Some(ev); + } + } + } + } + if let Some(ev) = finished_with { + let ok = matches!(ev, RunEvent::Exited(0)); + let notice = match ev { + RunEvent::Exited(0) => "✔ exit 0".to_string(), + RunEvent::Exited(code) => format!("✘ exit {code}"), + RunEvent::Failed(e) => format!("✘ no se pudo spawnear: {e}"), + _ => unreachable!(), + }; + s.push_in_block(run_block, OutputLine::notice(notice)); + // Cerrá el nodo del grafo de intenciones — el lienzo lo refleja + // como verde/rojo en el próximo render. + if let Some(id) = s.current_run_node.take() { + s.intent_graph.complete(id, ok, s.current_run_bytes); + } + s.current_run_bytes = 0; + s.running = None; + // Si quedó algo en cola, arrancarlo ya — sin esperar otro Tick. + if let Some(next) = s.queue.pop_front() { + s = start_run(s, next); + } + } + // Drenado de jobs background — cada uno aporta sus líneas + // prefijadas por `[N]`. Los terminados se eliminan del Vec. + s = drain_bg_jobs(s); + s +} + +/// Drena los `bg_jobs` y los limpia. Las líneas se prefijan `[N]` +/// para distinguir su origen. +pub(crate) fn drain_bg_jobs(mut s: State) -> State { + let mut next_jobs: Vec>> = Vec::with_capacity(s.bg_jobs.len()); + // Snapshot de los Arc: `push_output` toma `&mut s`, incompatible con + // retener el borrow de `s.bg_jobs` durante el loop. + let jobs = s.bg_jobs.clone(); + for arc in jobs.iter() { + let mut keep = true; + let mut finished: Option = None; + // Bloque propio del job — su salida vive en SU card, nunca en la + // del foreground (era el bug del "output mezclado"). + let job_block; + { + let mut guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + job_block = guard.block; + for ev in guard.handle.try_events() { + match ev { + RunEvent::Stdout(line) => s.push_in_block(job_block, OutputLine::stdout(line)), + RunEvent::StageStdout { stage, line } => { + s.push_in_block(job_block, OutputLine::stage_stdout(stage, line)) + } + RunEvent::Stderr(line) => s.push_in_block(job_block, OutputLine::stderr(line)), + RunEvent::Truncated => { + s.push_in_block(job_block, OutputLine::notice("… (truncada)")) + } + RunEvent::Spilled(path) => s.push_in_block( + job_block, + OutputLine::notice(format!("… (volcado a {path})")), + ), + RunEvent::Bytes(_) => { + // Background sin PTY — no debería emitir Bytes. + } + ev @ (RunEvent::Exited(_) | RunEvent::Failed(_)) => { + finished = Some(ev); + } + } + } + } + if let Some(ev) = finished { + let notice = match ev { + RunEvent::Exited(0) => "✔ exit 0".to_string(), + RunEvent::Exited(code) => format!("✘ exit {code}"), + RunEvent::Failed(e) => format!("✘ failed: {e}"), + _ => unreachable!(), + }; + s.push_in_block(job_block, OutputLine::notice(notice)); + keep = false; + } + if keep { + next_jobs.push(arc.clone()); + } + } + s.bg_jobs = next_jobs; + s +} + +pub(crate) fn cancel_running(mut s: State) -> State { + let mut run_block = s.current_block; + if let Some(arc) = s.running.as_ref() { + let guard = match arc.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + run_block = guard.block; + // Local: SIGKILL al grupo entero — Ctrl-C debe doler en una UI. + // Remoto: cerrar el stream — el daemon detecta EOF y mata al + // hijo. La forma del notice no cambia. + if let Some(killer) = guard.killer.as_ref() { + killer.kill(); + } else { + guard.handle.kill(); + } + // El próximo Tick observará `RunEvent::Exited` y limpiará el handle. + } + s.push_in_block(run_block, OutputLine::notice("⏹ cancel (SIGKILL enviado)")); + s +} + +pub(crate) fn apply_cd(mut s: State, rest: &str) -> State { + let target = if rest.trim().is_empty() { + // `cd` sin args → HOME (convención bash/zsh). + match std::env::var("HOME") { + Ok(h) => PathBuf::from(h), + Err(_) => { + s.push_output(OutputLine::notice("cd: HOME no está definido")); + return s; + } + } + } else { + let trimmed = rest.trim(); + let p = PathBuf::from(trimmed); + if p.is_absolute() { + p + } else { + s.cwd.join(p) + } + }; + match std::fs::canonicalize(&target) { + Ok(canonical) => { + if canonical.is_dir() { + s.cwd = canonical; + } else { + s.push_output(OutputLine::notice(format!( + "cd: no es un directorio: {}", + target.display() + ))); + } + } + Err(e) => { + s.push_output(OutputLine::notice(format!("cd: {}: {e}", target.display()))); + } + } + s +} + +pub(crate) fn split_first_word(line: &str) -> Option<(&str, &str)> { + let line = line.trim_start(); + if line.is_empty() { + return None; + } + match line.find(char::is_whitespace) { + Some(i) => Some((&line[..i], &line[i + 1..])), + None => Some((line, "")), + } +} + +pub(crate) fn push_line(buf: &mut Vec, line: OutputLine) { + buf.push(line); + let len = buf.len(); + if len > MAX_OUTPUT_LINES { + buf.drain(0..len - MAX_OUTPUT_LINES); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-module-shell/src/view.rs b/02_ruway/shuma/sandbox/shuma-module-shell/src/view.rs new file mode 100644 index 0000000..637e029 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module-shell/src/view.rs @@ -0,0 +1,2052 @@ +use super::*; + +pub fn view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + let header = shell_header(state, theme); + let main_panel: View = if is_tui_active(state) { + tui_panel::(state, theme, lift.clone()) + } else { + output_pane::(state, theme, &lift) + }; + // Panel de grupos [RUN] a la izquierda (rescate del shell GPUI): cada + // grupo guardado (`:save`) es una card clickable que lo ejecuta, con su + // tecla F. Sólo aparece si hay grupos y no estamos en un TUI fullscreen. + let body: View = if !state.groups.is_empty() && !is_tui_active(state) { + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_basis: length(0.0_f32), + flex_grow: 1.0, + min_size: Size { + width: Dimension::auto(), + height: length(0.0_f32), + }, + gap: Size { + width: length(8.0_f32), + height: length(0.0_f32), + }, + align_items: Some(AlignItems::Stretch), + ..Default::default() + }) + .children(vec![groups_panel::(state, theme, &lift), main_panel]) + } else { + main_panel + }; + let input = shell_input_view(state, theme, lift.clone()); + + let mut children = vec![header, body]; + // Banner de reprocess: el próximo comando recibe por stdin el stdout + // del bloque armado. Click → cancela (toggle). + if let Some(src) = state.reprocess_source { + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::SetReprocess(src))) + .text_aligned( + format!("» reprocesando la salida del bloque #{src} — Enter ejecuta · click cancela"), + 10.0, + theme.accent, + Alignment::Start, + ), + ); + } + // Popup de completado: justo encima del input, candidatos con el + // resaltado actual. Tab/flechas navegan, Enter acepta, Esc cierra. + if let Some(popup) = completion_popup::(state, theme) { + children.push(popup); + } + children.push(input); + if state.history_search.is_some() { + children.push(history_search_panel::(state, theme)); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(8.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .children(children) +} + +/// Color por `TokenKind` — paleta diseñada para que el comando salte y +/// los flags/strings tengan su propio tono. +pub(crate) fn token_color( + kind: TokenKind, + theme: &Theme, +) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + match kind { + TokenKind::Command => theme.accent, + TokenKind::Argument => theme.fg_text, + TokenKind::Flag => Color::from_rgba8(220, 200, 120, 255), // amarillo + TokenKind::StringLit => Color::from_rgba8(160, 210, 140, 255), // verde + TokenKind::Variable => Color::from_rgba8(200, 160, 220, 255), // violeta + TokenKind::Pipe | TokenKind::Redirect | TokenKind::Operator => theme.accent, + TokenKind::Comment | TokenKind::Whitespace => theme.fg_muted, + TokenKind::Unknown => theme.fg_destructive, + } +} + +/// Renderiza la línea de entrada con tokens coloreados, cursor visible +/// y ghost suggestion. El layout es un nodo único con `paint_with` — +/// medimos cada token con el typesetter en el closure para alinear el +/// cursor al carácter exacto. +pub(crate) fn shell_input_view( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + use llimphi_ui::llimphi_raster::peniko::Color; + let bg = if state.focused { + theme.bg_input_focus + } else { + theme.bg_input + }; + let border = if state.focused { + theme.border_focus + } else { + theme.border + }; + + let text = state.input.text().to_string(); + let cursor = state.input.cursor(); + let ghost = current_ghost(state); + let placeholder = if text.is_empty() && ghost.is_none() { + Some("tipeá un comando…".to_string()) + } else { + None + }; + // Multi-línea: cada `\n` agrega una línea visible y crece el alto + // del input. El cursor cae en (línea, columna) calculadas desde el + // byte offset del cursor. + let line_count = text.matches('\n').count() + 1; + const LINE_H: f64 = 18.0; + const BORDER_INNER_H: f64 = 16.0; // padding visual sumado al alto + let container_h = BORDER_INNER_H + LINE_H * line_count as f64; + let theme_clone = *theme; + let focused = state.focused; + + let painter = move |scene: &mut vello::Scene, + ts: &mut llimphi_ui::llimphi_text::Typesetter, + rect: llimphi_ui::PaintRect| { + use llimphi_ui::llimphi_text::{ + draw_layout, layout_block, measurement, Alignment as TAlign, TextBlock, + }; + let pad_x = 10.0; + let baseline_y = rect.y as f64 + 8.0; + let line_x_start = rect.x as f64 + pad_x; + + if let Some(ph) = &placeholder { + let block = TextBlock { + text: ph, + size_px: 13.0, + color: theme_clone.fg_placeholder, + origin: (line_x_start, baseline_y), + max_width: None, + alignment: TAlign::Start, + line_height: 1.2, + italic: false, + font_family: None, + }; + let layout = layout_block(ts, &block); + draw_layout( + scene, + &layout, + theme_clone.fg_placeholder, + (line_x_start, baseline_y), + ); + } + + // Calcular qué línea/columna ocupa el cursor. + let (cursor_line_idx, cursor_byte_in_line) = { + let pre = &text[..cursor]; + let line_idx = pre.matches('\n').count(); + let line_start = pre.rfind('\n').map(|i| i + 1).unwrap_or(0); + (line_idx, cursor - line_start) + }; + + let mut cursor_x: f64 = line_x_start; + let mut cursor_y: f64 = baseline_y; + let mut last_line_end_x: f64 = line_x_start; + let mut last_line_y: f64 = baseline_y; + let mut line_byte_start = 0usize; + for (line_idx, line_str) in text.split('\n').enumerate() { + let line_y = baseline_y + line_idx as f64 * LINE_H; + let mut x = line_x_start; + // Pintar tokens sobre el slice de la línea, usando el + // tokenizer estándar (dialect por defecto = bash). + let tokens = shuma_line::tokenize(line_str, state_dialect_default()); + for tok in &tokens { + let color = token_color(tok.kind, &theme_clone); + let segment = &line_str[tok.start..tok.end]; + let block = TextBlock { + text: segment, + size_px: 13.0, + color, + origin: (x, line_y), + max_width: None, + alignment: TAlign::Start, + line_height: 1.2, + italic: false, + font_family: None, + }; + let layout = layout_block(ts, &block); + let m = measurement(&layout); + draw_layout(scene, &layout, color, (x, line_y)); + if line_idx == cursor_line_idx + && tok.start < cursor_byte_in_line + && cursor_byte_in_line <= tok.end + { + let prefix = &line_str[tok.start..cursor_byte_in_line]; + if prefix.is_empty() { + cursor_x = x; + } else { + let pblock = TextBlock { + text: prefix, + size_px: 13.0, + color, + origin: (x, line_y), + max_width: None, + alignment: TAlign::Start, + line_height: 1.2, + italic: false, + font_family: None, + }; + let plat = layout_block(ts, &pblock); + cursor_x = x + measurement(&plat).width as f64; + } + cursor_y = line_y; + } + x += m.width as f64; + } + // Cursor al final de una línea vacía / sin tokens hasta el cursor. + if line_idx == cursor_line_idx + && (cursor_byte_in_line == line_str.len() || tokens.is_empty()) + { + cursor_x = x; + cursor_y = line_y; + } + last_line_end_x = x; + last_line_y = line_y; + line_byte_start += line_str.len() + 1; // +1 por el '\n' + } + let _ = line_byte_start; // sólo informativo + + // Ghost suggestion: sólo aplica si el cursor está al final del + // texto (última línea, columna final). Lo pinta detrás del cursor. + if let Some(suffix) = &ghost { + if !suffix.is_empty() && cursor == text.len() { + let block = TextBlock { + text: suffix, + size_px: 13.0, + color: theme_clone.fg_placeholder, + origin: (last_line_end_x, last_line_y), + max_width: None, + alignment: TAlign::Start, + line_height: 1.2, + italic: false, + font_family: None, + }; + let layout = layout_block(ts, &block); + draw_layout( + scene, + &layout, + theme_clone.fg_placeholder, + (last_line_end_x, last_line_y), + ); + } + } + + // Cursor — barra vertical de 2 px en la línea calculada. + if focused { + use llimphi_ui::llimphi_raster::kurbo::Rect as KurboRect; + use llimphi_ui::llimphi_raster::peniko::Fill; + let cursor_rect = + KurboRect::new(cursor_x, cursor_y + 2.0, cursor_x + 2.0, cursor_y + LINE_H); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + Color::from_rgba8(214, 222, 232, 220), + None, + &cursor_rect, + ); + } + }; + + let inner = View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .radius(3.0) + .paint_with(painter); + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(container_h as f32), + }, + padding: Rect { + left: length(1.0_f32), + right: length(1.0_f32), + top: length(1.0_f32), + bottom: length(1.0_f32), + }, + ..Default::default() + }) + .fill(border) + .radius(4.0) + .on_click(lift(Msg::FocusInput)) + .children(vec![inner]) +} + +/// Dialect por defecto para el painter — el `LineState` lo guarda +/// internamente pero no lo expone; mientras todos los usos sean bash +/// alcanza con este getter. +pub(crate) fn state_dialect_default() -> shuma_line::Dialect { + shuma_line::Dialect::default() +} + +/// Panel de TUI app-aware: según el programa bajo el PTY elige un skin. +/// `is_tui_active(state)` ya garantiza que hay un run con PTY. vim se +/// pinta como un card themeable; el resto cae al grid vt100 crudo. +pub(crate) fn tui_panel( + state: &State, + theme: &Theme, + lift: impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +) -> View { + // Snapshot + skin en un solo lock; la closure de paint debe ser + // `Send + Sync`, así que no captura el Mutex. + let (snapshot, skin) = match state.running.as_ref().and_then(|arc| arc.lock().ok()) { + Some(g) => { + let skin = g.tui.as_ref().map(|t| t.skin).unwrap_or(AppSkin::Generic); + (capture_tui(&g), skin) + } + None => (None, AppSkin::Generic), + }; + let rect_slot = Arc::clone(&state.last_tui_rect); + if let AppSkin::Vim = skin { + let metrics_slot = Arc::clone(&state.vim_metrics); + return vim_panel::( + snapshot, + theme, + rect_slot, + metrics_slot, + state.vim_sel, + lift, + ); + } + generic_grid_panel::(snapshot, theme, rect_slot) +} + +/// Render de grilla vt100 cruda — el camino histórico para htop/less/man. +pub(crate) fn generic_grid_panel( + snapshot: Option, + theme: &Theme, + rect_slot: Arc>, +) -> View { + let theme_clone = *theme; + + let painter = move |scene: &mut vello::Scene, + ts: &mut llimphi_ui::llimphi_text::Typesetter, + rect: llimphi_ui::PaintRect| { + use llimphi_ui::llimphi_raster::kurbo::Rect as KurboRect; + use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; + use llimphi_ui::llimphi_text::{draw_layout, layout_block, Alignment as TAlign, TextBlock}; + // Publica el rect al state — el próximo Tick disparará resize + // si las dims cambiaron. + if let Ok(mut g) = rect_slot.lock() { + *g = (rect.w, rect.h); + } + let Some(snap) = &snapshot else { return }; + // Tamaño de la celda derivado del rect disponible. Monoespacio, + // ancho/alto fijos por celda. Si el panel es chico el grid + // se recorta abajo/derecha (no scrolleamos por ahora). + let pad = 6.0_f64; + let avail_w = (rect.w as f64 - pad * 2.0).max(0.0); + let avail_h = (rect.h as f64 - pad * 2.0).max(0.0); + let cell_w = (avail_w / snap.cols as f64).max(1.0); + let cell_h = (avail_h / snap.rows as f64).max(1.0); + let font_size = (cell_h * 0.75).clamp(8.0, 18.0) as f32; + let origin_x = rect.x as f64 + pad; + let origin_y = rect.y as f64 + pad; + + // Backgrounds primero (en bloques rect), texto encima. + for (r, row) in snap.cells.iter().enumerate() { + for (c, cell) in row.iter().enumerate() { + let bg = vt_color(cell.bg, theme_clone, true); + if bg.components[3] > 0.0 { + let x0 = origin_x + c as f64 * cell_w; + let y0 = origin_y + r as f64 * cell_h; + let rect = KurboRect::new(x0, y0, x0 + cell_w, y0 + cell_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + bg, + None, + &rect, + ); + } + } + } + // Texto por celda. Para reducir shaping, agrupamos runs con + // mismo color contiguo en la misma fila. + for (r, row) in snap.cells.iter().enumerate() { + let mut c = 0usize; + while c < row.len() { + let fg = vt_color(row[c].fg, theme_clone, false); + let mut end = c + 1; + let mut buf = String::new(); + buf.push_str(&row[c].ch); + while end < row.len() && row[end].fg == row[c].fg { + buf.push_str(&row[end].ch); + end += 1; + } + if !buf.trim().is_empty() { + let x0 = origin_x + c as f64 * cell_w; + let y0 = origin_y + r as f64 * cell_h; + let block = TextBlock { + text: &buf, + size_px: font_size, + color: fg, + origin: (x0, y0), + max_width: None, + alignment: TAlign::Start, + line_height: 1.0, + italic: false, + font_family: None, + }; + let layout = layout_block(ts, &block); + draw_layout(scene, &layout, fg, (x0, y0)); + } + c = end; + } + } + // Cursor: barra vertical en (cursor_r, cursor_c). + if !snap.hide_cursor { + let x0 = origin_x + snap.cursor_c as f64 * cell_w; + let y0 = origin_y + snap.cursor_r as f64 * cell_h; + let rect = KurboRect::new(x0, y0 + 2.0, x0 + 2.0, y0 + cell_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + Color::from_rgba8(214, 222, 232, 220), + None, + &rect, + ); + } + }; + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(3.0) + .paint_with(painter) +} + +/// Skin de vim: reconstruye cada fila del `Screen` como una línea de +/// texto en la paleta del tema — sin la grilla de celdas ni los `~` de +/// relleno —, con la última fila como barra de estado. El contenido se +/// lee como un output normal, dentro del card del panel; las teclas +/// siguen yendo al PTY (vim sigue siendo interactivo). +/// +/// MVP: read-only (la selección/click-derecho-pegar nativos vienen +/// después, sobre el widget de texto). El objetivo de este paso es que +/// vim deje de verse "como por un vidrio". +/// Geometría del card de vim — compartida entre el painter (resaltado) +/// y `copy_vim_selection` (px → celda) para que las celdas coincidan. +/// `VIM_PAD` es fijo (margen del panel); el avance horizontal y el alto +/// de línea son *fallbacks* — los reales los mide el painter sobre el +/// layout de parley y los publica en `State::vim_metrics`. +pub(crate) const VIM_PAD: f64 = 10.0; +pub(crate) const VIM_LINE_H: f64 = 16.0; +pub(crate) const VIM_CHAR_W: f64 = 7.8; +pub(crate) const VIM_FONT_PX: f32 = 13.0; + +/// Coordenadas locales (px, relativas al rect del panel) → celda (fila, +/// col), con las métricas reales del monospace (`char_w`, `line_h`). +pub(crate) fn vim_px_to_cell(x: f64, y: f64, char_w: f64, line_h: f64) -> (usize, usize) { + let col = (((x - VIM_PAD) / char_w).floor()).max(0.0) as usize; + let row = (((y - VIM_PAD) / line_h).floor()).max(0.0) as usize; + (row, col) +} + +pub(crate) fn vim_panel( + snapshot: Option, + theme: &Theme, + rect_slot: Arc>, + metrics_slot: Arc>, + sel: Option, + lift: L, +) -> View +where + HostMsg: Clone + 'static, + L: Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static, +{ + let theme_clone = *theme; + let lift_drag = lift.clone(); + let painter = move |scene: &mut vello::Scene, + ts: &mut llimphi_ui::llimphi_text::Typesetter, + rect: llimphi_ui::PaintRect| { + use llimphi_ui::llimphi_raster::kurbo::Rect as KurboRect; + use llimphi_ui::llimphi_raster::peniko::{Color, Fill}; + use llimphi_ui::llimphi_text::{draw_layout, layout_block, Alignment as TAlign, TextBlock}; + // Publica el rect para que el próximo Tick dispare resize si cambió. + if let Ok(mut g) = rect_slot.lock() { + *g = (rect.w, rect.h); + } + let Some(snap) = &snapshot else { return }; + let pad = VIM_PAD; + let font = VIM_FONT_PX; + // Métricas reales del monospace: medimos un bloque-sonda de 40 + // glifos idénticos y dividimos para el avance horizontal; el alto + // del layout (line_height 1.0) da el alto de línea. Adivinar las + // constantes desfasa el resaltado al acumularse por columna. + const PROBE: &str = "0000000000000000000000000000000000000000"; // 40 + let probe = TextBlock { + text: PROBE, + size_px: font, + color: theme_clone.fg_text, + origin: (0.0, 0.0), + max_width: None, + alignment: TAlign::Start, + line_height: 1.0, + italic: false, + font_family: None, + }; + let m = llimphi_ui::llimphi_text::measure(ts, &probe); + let char_w = if m.width > 1.0 { + (m.width as f64) / PROBE.len() as f64 + } else { + VIM_CHAR_W + }; + let line_h = if m.height > 1.0 { + m.height as f64 + } else { + VIM_LINE_H + }; + // Publica las métricas para que `copy_vim_selection` use las mismas. + if let Ok(mut g) = metrics_slot.lock() { + *g = (char_w as f32, line_h as f32); + } + let origin_x = rect.x as f64 + pad; + let origin_y = rect.y as f64 + pad; + let n = snap.cells.len(); + // Resaltado de la selección (drag): un rect translúcido por fila. + if let Some(vs) = sel { + let (r0, c0) = vim_px_to_cell(vs.ax as f64, vs.ay as f64, char_w, line_h); + let (r1, c1) = vim_px_to_cell(vs.hx as f64, vs.hy as f64, char_w, line_h); + let (sr, sc, er, ec) = if (r0, c0) <= (r1, c1) { + (r0, c0, r1, c1) + } else { + (r1, c1, r0, c0) + }; + let ncols = snap.cells.first().map(|row| row.len()).unwrap_or(0); + let er = er.min(n.saturating_sub(1)); + let bg = theme_clone.bg_selected; + let sel_color = Color::from_rgba8( + (bg.components[0] * 255.0) as u8, + (bg.components[1] * 255.0) as u8, + (bg.components[2] * 255.0) as u8, + 120, + ); + for r in sr..=er { + let lo = if r == sr { sc } else { 0 }; + let hi = if r == er { (ec + 1).min(ncols) } else { ncols }; + if hi <= lo { + continue; + } + let x0 = origin_x + lo as f64 * char_w; + let x1 = origin_x + hi as f64 * char_w; + let y0 = origin_y + r as f64 * line_h; + let hrect = KurboRect::new(x0, y0, x1, y0 + line_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + sel_color, + None, + &hrect, + ); + } + } + for (r, row) in snap.cells.iter().enumerate() { + let raw: String = row.iter().map(|c| c.ch.as_str()).collect(); + let line_str = raw.trim_end(); + // La última fila es la barra de estado / línea de comando de vim. + let is_status = n > 1 && r + 1 == n; + // Relleno de vim: una fila cuyo único contenido es `~`. + if !is_status && line_str.trim_start() == "~" { + continue; + } + let y = origin_y + r as f64 * line_h; + let color = if is_status { + theme_clone.accent + } else { + theme_clone.fg_text + }; + if is_status { + // Fondo sutil para distinguir la barra de estado del buffer. + let bar = + KurboRect::new(rect.x as f64, y - 2.0, (rect.x + rect.w) as f64, y + line_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + theme_clone.bg_input, + None, + &bar, + ); + } + if !line_str.is_empty() { + let block = TextBlock { + text: line_str, + size_px: font, + color, + origin: (origin_x, y), + max_width: None, + alignment: TAlign::Start, + line_height: 1.0, + italic: false, + font_family: None, + }; + let layout = layout_block(ts, &block); + draw_layout(scene, &layout, color, (origin_x, y)); + } + } + // Cursor: barra vertical en la posición del cursor de vim. + if !snap.hide_cursor { + let x0 = origin_x + snap.cursor_c as f64 * char_w; + let y0 = origin_y + snap.cursor_r as f64 * line_h; + let cur = KurboRect::new(x0, y0 + 2.0, x0 + 2.0, y0 + line_h); + scene.fill( + Fill::NonZero, + vello::kurbo::Affine::IDENTITY, + Color::from_rgba8(214, 222, 232, 220), + None, + &cur, + ); + } + }; + + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + flex_grow: 1.0, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(3.0) + .paint_with(painter) + // Selección estilo terminal: arrastrar con el botón izquierdo + // selecciona celdas; al soltar se copia al clipboard. + .draggable_at(move |phase, dx, dy, lx0, ly0| { + Some(lift_drag(Msg::VimDrag { + end: matches!(phase, llimphi_ui::DragPhase::End), + dx, + dy, + ax: lx0, + ay: ly0, + })) + }) + // Paste estilo terminal: click derecho y botón del medio pegan el + // clipboard al PTY (vim sigue recibiendo las teclas aparte). + .on_right_click(lift(Msg::VimPaste)) + .on_middle_click(lift(Msg::VimPaste)) +} + +/// Snapshot copiable del Screen para enviar a una closure `paint_with`. +pub(crate) struct TuiSnapshot { + cells: Vec>, + rows: u16, + cols: u16, + cursor_r: u16, + cursor_c: u16, + hide_cursor: bool, +} + +#[derive(Clone)] +pub(crate) struct TuiCell { + ch: String, + fg: vt100::Color, + bg: vt100::Color, +} + +/// Copia el screen actual de un `ActiveRun` PTY a un snapshot +/// `Send`-able. Devuelve `None` si el run no es TUI. +pub(crate) fn capture_tui(active: &std::sync::MutexGuard<'_, ActiveRun>) -> Option { + let tui = active.tui.as_ref()?; + let screen = tui.parser.screen(); + let (rows, cols) = screen.size(); + let mut cells: Vec> = Vec::with_capacity(rows as usize); + for r in 0..rows { + let mut row: Vec = Vec::with_capacity(cols as usize); + for c in 0..cols { + let (ch, fg, bg) = match screen.cell(r, c) { + Some(cell) => ( + if cell.has_contents() { + cell.contents().to_string() + } else { + " ".to_string() + }, + cell.fgcolor(), + cell.bgcolor(), + ), + None => (" ".into(), vt100::Color::Default, vt100::Color::Default), + }; + row.push(TuiCell { ch, fg, bg }); + } + cells.push(row); + } + let (cursor_r, cursor_c) = screen.cursor_position(); + Some(TuiSnapshot { + cells, + rows, + cols, + cursor_r, + cursor_c, + hide_cursor: screen.hide_cursor(), + }) +} + +/// Convierte un `vt100::Color` a un `peniko::Color`, respetando el tema +/// del shell (los 16 índices ANSI se mapean a una paleta consistente). +pub(crate) fn vt_color( + c: vt100::Color, + theme: Theme, + is_bg: bool, +) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + match c { + vt100::Color::Default => { + if is_bg { + // Transparent — el panel ya tiene su propio fill. + Color::from_rgba8(0, 0, 0, 0) + } else { + theme.fg_text + } + } + vt100::Color::Rgb(r, g, b) => Color::from_rgba8(r, g, b, 255), + vt100::Color::Idx(i) => ansi_idx_to_color(i), + } +} + +/// Mapeo 256 → RGB usando la paleta xterm estándar. Cubre los 16 +/// básicos, el cubo 6×6×6 y la rampa de grises. +pub(crate) fn ansi_idx_to_color(i: u8) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + const BASIC: [[u8; 3]; 16] = [ + [0, 0, 0], + [205, 49, 49], + [13, 188, 121], + [229, 229, 16], + [36, 114, 200], + [188, 63, 188], + [17, 168, 205], + [229, 229, 229], + [102, 102, 102], + [241, 76, 76], + [35, 209, 139], + [245, 245, 67], + [59, 142, 234], + [214, 112, 214], + [41, 184, 219], + [255, 255, 255], + ]; + if i < 16 { + let [r, g, b] = BASIC[i as usize]; + return Color::from_rgba8(r, g, b, 255); + } + if i >= 232 { + let v = 8 + (i - 232) * 10; + return Color::from_rgba8(v, v, v, 255); + } + let i = i - 16; + let r = i / 36; + let g = (i / 6) % 6; + let b = i % 6; + let to_byte = |x: u8| if x == 0 { 0 } else { 55 + x * 40 }; + Color::from_rgba8(to_byte(r), to_byte(g), to_byte(b), 255) +} + +/// Overlay de búsqueda Ctrl-R. Vive como hijo extra del root cuando +/// `state.history_search` está activo; un input + lista de matches. +pub(crate) fn history_search_panel( + state: &State, + theme: &Theme, +) -> View { + let search = state + .history_search + .as_ref() + .expect("panel sólo se construye con search activo"); + let matches: Vec = { + let history = state.history.lock().unwrap(); + history + .fuzzy_search(&search.query, 50) + .into_iter() + .map(|e| e.line.clone()) + .collect() + }; + let label = format!("Ctrl-R › {}", search.query); + let mut children: Vec> = vec![View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(20.0_f32), + }, + ..Default::default() + }) + .text_aligned(label, 12.0, theme.accent, Alignment::Start)]; + + for (i, m) in matches.iter().enumerate().take(8) { + let color = if i == search.selected { + theme.accent + } else { + theme.fg_text + }; + let bg = if i == search.selected { + theme.bg_selected + } else { + theme.bg_panel + }; + children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + ..Default::default() + }) + .fill(bg) + .text_aligned(m.clone(), 12.0, color, Alignment::Start), + ); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(8.0_f32), + bottom: length(8.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(2.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(3.0) + .children(children) +} + +pub(crate) fn shell_header( + state: &State, + theme: &Theme, +) -> View { + let status = if let Some(arc) = state.running.as_ref() { + let cmd = match arc.lock() { + Ok(g) => g.command.clone(), + Err(p) => p.into_inner().command.clone(), + }; + let queued = state.queue.len(); + if queued > 0 { + format!(" · ⟳ {cmd} (+{queued} en cola)") + } else { + format!(" · ⟳ {cmd}") + } + } else { + String::new() + }; + // Rama git del cwd, si estamos en un repo (`· (main)`). La fuente del + // shell no trae el glifo ⎇, así que usamos la convención de paréntesis. + let branch = match git_branch(&state.cwd) { + Some(b) => format!(" · ({b})"), + None => String::new(), + }; + let label = format!( + "Shell · {} · cwd: {}{}{}", + state.source.label(), + pretty_path(&state.cwd), + branch, + status, + ); + let color = if state.is_running() { + theme.accent + } else { + theme.fg_text + }; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(24.0_f32), + }, + ..Default::default() + }) + .text_aligned(label, 12.0, color, Alignment::Start) +} + +/// Panel de grupos `[RUN]` a la izquierda: una card por grupo guardado +/// (`:save`), clickable para ejecutarlo, con su tecla F. Ancho fijo. El +/// caller ya garantizó que hay ≥1 grupo. +pub(crate) fn groups_panel( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + const PANEL_W: f32 = 176.0; + let mut children: Vec> = vec![View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(18.0_f32), + }, + ..Default::default() + }) + .text_aligned("GRUPOS".to_string(), 10.0, theme.fg_muted, Alignment::Start)]; + + for (i, g) in state.groups.iter().enumerate() { + let title = format!("F{} {}", i + 1, g.name); + let sub = format!("{} cmds", g.lines.len()); + let card = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: length(38.0_f32), + }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(3.0_f32), + bottom: length(3.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(4.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::RunGroup(i))) + .children(vec![ + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }) + .text_aligned(title, 12.0, theme.accent, Alignment::Start), + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(14.0_f32), + }, + ..Default::default() + }) + .text_aligned(sub, 10.0, theme.fg_muted, Alignment::Start), + ]); + children.push(card); + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: length(PANEL_W), + height: percent(1.0_f32), + }, + flex_shrink: 0.0, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(4.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(3.0) + .children(children) +} + +/// Popup de completado: lista de candidatos con el actual resaltado. Se +/// pinta sobre el input (en la columna, justo antes). Acota a `MAX_ROWS` +/// filas visibles centradas en el índice. `None` si no hay popup abierto. +pub(crate) fn completion_popup( + state: &State, + theme: &Theme, +) -> Option> { + let comp = state.completion.as_ref()?; + if comp.candidates.is_empty() { + return None; + } + const MAX_ROWS: usize = 8; + const ROW: f32 = 18.0; + let n = comp.candidates.len(); + let sel = state.completion_index.min(n - 1); + // Ventana deslizante centrada en la selección. + let start = sel.saturating_sub(MAX_ROWS / 2).min(n.saturating_sub(MAX_ROWS)); + let end = (start + MAX_ROWS).min(n); + + let mut rows: Vec> = Vec::new(); + for (i, cand) in comp.candidates[start..end].iter().enumerate() { + let idx = start + i; + let selected = idx == sel; + let (fill, fg) = if selected { + (theme.accent, theme.bg_panel) + } else { + (theme.bg_input, theme.fg_text) + }; + rows.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(ROW), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(fill) + .text_aligned(cand.clone(), 12.0, fg, Alignment::Start), + ); + } + // Pie con el conteo cuando hay más de lo que entra. + let mut total_rows = rows.len(); + if n > MAX_ROWS { + rows.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(ROW), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .text_aligned( + format!("{}/{} · Tab/↑↓ navega · Enter acepta · Esc cierra", sel + 1, n), + 10.0, + theme.fg_muted, + Alignment::Start, + ), + ); + total_rows += 1; + } + + Some( + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: length(total_rows as f32 * ROW + 4.0), + }, + padding: Rect { + left: length(2.0_f32), + right: length(2.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(4.0) + .children(rows), + ) +} + +// Geometría fija del panel de output. Debe coincidir EXACTAMENTE con los +// `Style` de `output_pane`/`command_card`: el scroll calcula `content_h` +// con estas constantes (no medimos el árbol; con alturas fijas alcanza). +pub(crate) const PANE_PAD_V: f32 = 12.0; // padding top 6 + bottom 6 del column interno +pub(crate) const PANE_GAP: f32 = 6.0; // gap entre cards / líneas sueltas +pub(crate) const CARD_PAD_V: f32 = 9.0; // card padding top 4 + bottom 5 +pub(crate) const CARD_GAP: f32 = 2.0; // gap entre hijos de la card +pub(crate) const HEADER_H: f32 = 20.0; // header de la card +pub(crate) const STAGES_H: f32 = 20.0; // fila de etapas de pipe +pub(crate) const ROW_H: f32 = 16.0; // una línea de output + +pub(crate) fn output_pane( + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + const MAX_VISIBLE: usize = 400; + let start = state.output.len().saturating_sub(MAX_VISIBLE); + let visible = &state.output[start..]; + + // Agrupamos por `block` COLECTANDO todas las líneas del bloque aunque + // se intercalen en el buffer (un job de fondo que escupe entre líneas + // del foreground ya no fragmenta ni contamina ninguna card). El orden + // de las cards es el de primera aparición del bloque. + let mut order: Vec = Vec::new(); + let mut groups: std::collections::HashMap> = + std::collections::HashMap::new(); + for line in visible { + if !groups.contains_key(&line.block) { + order.push(line.block); + } + groups.entry(line.block).or_default().push(line); + } + + // Cada item lleva su alto exacto → `content_h` para el scroll. + let mut items: Vec<(View, f32)> = Vec::new(); + for id in &order { + let g = &groups[id]; + if g.first() + .map(|l| l.kind == OutputKind::Prompt) + .unwrap_or(false) + { + items.push(command_card::( + g.as_slice(), + *id, + state, + theme, + lift, + )); + } else { + // Líneas sueltas (tope parcial tras capar, notices iniciales). + for &line in g.iter() { + items.push(( + render_output_line::(line, &state.cwd, theme, lift), + ROW_H, + )); + } + } + } + + let content_h = if items.is_empty() { + PANE_PAD_V + } else { + PANE_PAD_V + + items.iter().map(|(_, h)| *h).sum::() + + PANE_GAP * (items.len() as f32 - 1.0) + }; + let children: Vec> = items.into_iter().map(|(v, _)| v).collect(); + + // Scroll: el viewport lo midió el painter el frame anterior. Por + // defecto pegado al fondo (lo último visible, como una terminal); + // `scroll_px` (rueda) desplaza hacia el historial. Publicamos el + // overflow para que `Msg::Scroll` clampe sin recomputar geometría. + let viewport_h = state.out_viewport_h.lock().map(|g| *g).unwrap_or(0.0); + let overflow = (content_h - viewport_h).max(0.0); + if let Ok(mut g) = state.out_overflow.lock() { + *g = overflow; + } + let ty: f64 = if viewport_h < 1.0 { + 0.0 // primer frame, todavía sin medir → tope + } else { + (state.scroll_px.clamp(0.0, overflow) - overflow) as f64 + }; + + let inner = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + padding: Rect { + left: length(8.0_f32), + right: length(8.0_f32), + top: length(6.0_f32), + bottom: length(6.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(PANE_GAP), + }, + align_items: Some(AlignItems::Stretch), + ..Default::default() + }) + .transform(vello::kurbo::Affine::translate((0.0, ty))) + .children(children); + + // El painter publica el alto del viewport; coexiste con los hijos + // (el compositor pinta painter y luego children). + let slot = Arc::clone(&state.out_viewport_h); + let painter = move |_scene: &mut vello::Scene, + _ts: &mut llimphi_ui::llimphi_text::Typesetter, + rect: llimphi_ui::PaintRect| { + if let Ok(mut g) = slot.lock() { + *g = rect.h; + } + }; + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + // Región scrolleable en una flex column: `flex_basis: 0` + + // `min_height: 0` para que tome SÓLO el espacio sobrante (tras el + // header y el input) y NO el tamaño de su contenido. Sin esto el + // alto del contenido (un `ls` largo) se filtra al flex-basis y el + // panel aplasta/expulsa el input. El contenido se clipa adentro. + flex_basis: length(0.0_f32), + flex_grow: 1.0, + min_size: Size { + width: Dimension::auto(), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel) + .radius(3.0) + .clip(true) + .paint_with(painter) + .children(vec![inner]) +} + +/// Color del badge de estado a partir del texto de la notice de cierre +/// (`✔ exit 0`, `✘ exit N`, `⏹ cancel …`). `None` si la línea no es un +/// estado de cierre — se queda en el cuerpo de la card. +pub(crate) fn status_color( + text: &str, + theme: &Theme, +) -> Option { + use llimphi_ui::llimphi_raster::peniko::Color; + let t = text.trim_start(); + if t.starts_with('✔') { + Some(Color::from_rgba8(120, 200, 140, 255)) // verde "ok" + } else if t.starts_with('✘') || t.starts_with('⏹') { + Some(theme.fg_destructive) + } else { + None + } +} + +/// Extrae el comando crudo del texto del header (`$ ls | wc`, o el de un +/// job de fondo `[0] $ sleep 5 &`) — para parsear las etapas del pipe. +pub(crate) fn extract_command(header: &str) -> String { + let after = header.splitn(2, "$ ").nth(1).unwrap_or(header); + after.trim().trim_end_matches('&').trim_end().to_string() +} + +/// Fila de etapas de un pipe: `⇢ a | b | c`, cada etapa clickable para +/// re-ejecutar la línea truncada hasta ahí (inspeccionar intermedios). +/// `None` si la línea no es un pipe de ≥2 etapas. Recuperada del shuma +/// GPUI viejo (commit 3751aadb), ahora sobre Llimphi. +pub(crate) fn pipe_stages_row( + header_text: &str, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> Option> { + let cmd = extract_command(header_text); + let toks = shuma_line::tokenize(&cmd, state_dialect_default()); + let pipe = shuma_line::split_pipeline(&toks); + if pipe.stages.len() < 2 { + return None; + } + let raw_parts: Vec<&str> = cmd.split('|').collect(); + let mut row_children: Vec> = vec![View::new(Style { + size: Size { + width: length(16.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }) + .text_aligned("⇢".to_string(), 11.0, theme.fg_muted, Alignment::Start)]; + + for (i, st) in pipe.stages.iter().enumerate() { + let label = st + .command + .clone() + .unwrap_or_else(|| format!("etapa {}", i + 1)); + // Prefijo a re-ejecutar: la línea hasta esta etapa, inclusive. + let prefix = raw_parts + .get(..=i) + .map(|p| p.join("|").trim().to_string()) + .unwrap_or_else(|| cmd.clone()); + let l = lift.clone(); + row_children.push( + View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(16.0_f32), + }, + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(l(Msg::RunLine(prefix))) + .text_aligned(label, 11.0, theme.fg_text, Alignment::Start), + ); + } + + Some( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(STAGES_H), + }, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(5.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(row_children), + ) +} + +/// Paleta de etapa — hues desaturados, en la misma familia que la de +/// tokens. Cicla a las 6; un pipe con más etapas reusa colores, sigue +/// siendo legible. +const STAGE_PALETTE: [(u8, u8, u8); 6] = [ + (130, 195, 205), // teal + (220, 190, 120), // ámbar + (160, 205, 150), // verde + (195, 160, 215), // violeta + (220, 160, 150), // coral + (150, 180, 225), // azul +]; + +/// Color estable por índice de etapa — para que cada etapa del pipe lea +/// distinto de un vistazo (chip + sus líneas + su barra-guía). +pub(crate) fn stage_color(i: usize) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + let (r, g, b) = STAGE_PALETTE[i % STAGE_PALETTE.len()]; + Color::from_rgba8(r, g, b, 255) +} + +/// Misma tinta, atenuada (alfa 80%) — para el texto de las líneas +/// capturadas: menos peso visual que el chip que las titula. +fn stage_color_dim(i: usize) -> llimphi_ui::llimphi_raster::peniko::Color { + use llimphi_ui::llimphi_raster::peniko::Color; + let (r, g, b) = STAGE_PALETTE[i % STAGE_PALETTE.len()]; + Color::from_rgba8(r, g, b, 204) +} + +/// Bytes a etiqueta compacta: `840`, `1.2K`, `3.4M`. Sin espacio para que +/// quepa en el chip. +fn humanize_bytes(n: usize) -> String { + if n < 1024 { + format!("{n}B") + } else if n < 1024 * 1024 { + format!("{:.1}K", n as f32 / 1024.0) + } else { + format!("{:.1}M", n as f32 / (1024.0 * 1024.0)) + } +} + +/// Fila de etapas con **captura en vivo** (tee): cada chip despliega las +/// líneas intermedias ya capturadas de su etapa, sin re-ejecutar. Devuelve +/// `(views, alto)` — la fila de chips más, por cada etapa desplegada, sus +/// líneas. `stage_lines` son las `OutputLine` con `stage = Some(_)` del +/// bloque. La última etapa no se captura (su salida es el cuerpo). +pub(crate) fn stage_capture_rows( + header_text: &str, + stage_lines: &[&OutputLine], + block: u64, + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> (Vec>, f32) { + let cmd = extract_command(header_text); + let toks = shuma_line::tokenize(&cmd, state_dialect_default()); + let pipe = shuma_line::split_pipeline(&toks); + if pipe.stages.len() < 2 { + return (Vec::new(), 0.0); + } + + // Chips de etapa. + let mut row_children: Vec> = vec![View::new(Style { + size: Size { + width: length(16.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }) + .text_aligned("⇢".to_string(), 11.0, theme.fg_muted, Alignment::Start)]; + + for (i, st) in pipe.stages.iter().enumerate() { + let captured = stage_lines.iter().filter(|l| l.stage == Some(i)).count(); + let bytes: usize = stage_lines + .iter() + .filter(|l| l.stage == Some(i)) + .map(|l| l.text.len()) + .sum(); + let expanded = state.expanded_stages.contains(&(block, i)); + let base = st + .command + .clone() + .unwrap_or_else(|| format!("etapa {}", i + 1)); + // Conteo doble (líneas + bytes) sólo cuando hay captura. + let label = if captured > 0 { + format!("{base} {captured}L {}", humanize_bytes(bytes)) + } else { + base + }; + // La última etapa no tiene captura (su salida es el cuerpo): chip + // inerte, en color tenue, para que se vea la estructura del pipe. + let is_last = i + 1 == pipe.stages.len(); + let fill = if expanded { + theme.bg_row_hover + } else { + theme.bg_input + }; + // Color estable por etapa para las que capturan; la última, tenue. + let txt_color = if is_last { + theme.fg_muted + } else { + stage_color(i) + }; + let mut chip = View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(16.0_f32), + }, + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(fill) + .radius(3.0) + .text_aligned(label, 11.0, txt_color, Alignment::Start); + if !is_last { + chip = chip + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::ToggleStage { block, stage: i })); + } + row_children.push(chip); + } + + let chips_row = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(STAGES_H), + }, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(5.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(row_children); + + let mut out: Vec> = vec![chips_row]; + let mut height = STAGES_H; + + // Líneas capturadas de cada etapa desplegada, en orden de etapa. Cada + // etapa va como un bloque `Row[barra-guía coloreada | columna de + // líneas]`: la barra ata visualmente las líneas a su chip por color. + for (i, _st) in pipe.stages.iter().enumerate() { + if !state.expanded_stages.contains(&(block, i)) { + continue; + } + let lines: Vec<&&OutputLine> = + stage_lines.iter().filter(|l| l.stage == Some(i)).collect(); + let color = stage_color(i); + let dim = stage_color_dim(i); + + // Columna de líneas (o el placeholder si la etapa aún no emitió). + let mut col_children: Vec> = Vec::new(); + let block_h = if lines.is_empty() { + col_children.push( + row_text(ROW_H) + .text_aligned( + "(sin líneas capturadas)".to_string(), + 11.0, + theme.fg_muted, + Alignment::Start, + ), + ); + ROW_H + } else { + for l in &lines { + col_children.push( + row_text(ROW_H) + .text_aligned(l.text.clone(), 12.0, dim, Alignment::Start), + ); + } + lines.len() as f32 * ROW_H + }; + + let col = View::new(Style { + flex_direction: FlexDirection::Column, + flex_grow: 1.0, + size: Size { + width: Dimension::auto(), + height: length(block_h), + }, + ..Default::default() + }) + .children(col_children); + + // Barra-guía: 2px de ancho, estira al alto del bloque (align-items + // stretch por defecto en el Row), con sangría a izquierda. + let bar = View::new(Style { + size: Size { + width: length(2.0_f32), + height: percent(1.0_f32), + }, + margin: Rect { + left: length(8.0_f32), + right: length(6.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(color) + .radius(1.0); + + out.push( + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(block_h), + }, + ..Default::default() + }) + .children(vec![bar, col]), + ); + height += block_h; + } + + (out, height) +} + +/// Una fila de texto de alto `h`, ancho completo, sin padding lateral — +/// la sangría la da la barra-guía del bloque de etapa. +fn row_text(h: f32) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(h), + }, + ..Default::default() + }) +} + +/// Renderiza un bloque-comando como card desplegable: header (chevron + +/// comando + badge de estado, clickable para plegar), opcional fila de +/// etapas de pipe, y cuerpo (la salida, oculta si está colapsado). +/// `group[0]` es el `Prompt`. Devuelve `(view, alto_exacto)` — el alto +/// alimenta el cálculo de scroll de `output_pane`. +pub(crate) fn command_card( + group: &[&OutputLine], + block: u64, + state: &State, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> (View, f32) { + let collapsed = state.collapsed.contains(&block); + let header_text = group[0].text.clone(); + + // Separamos la notice de cierre (se promueve a badge), las líneas de + // etapas intermedias (tee — van a su desplegable) y el resto (cuerpo). + // Si hay varias notices de cierre, gana la última. + let mut body: Vec<&OutputLine> = Vec::new(); + let mut stage_lines: Vec<&OutputLine> = Vec::new(); + let mut badge: Option<(String, llimphi_ui::llimphi_raster::peniko::Color)> = None; + for &l in &group[1..] { + if l.stage.is_some() { + stage_lines.push(l); + } else if let Some(color) = status_color(&l.text, theme) { + badge = Some((l.text.clone(), color)); + } else { + body.push(l); + } + } + // Comando aún vivo (sin notice de cierre todavía): spinner en accent. + // (Foreground o job de fondo: ambos siguen "vivos" hasta su exit.) + let still_running = badge.is_none() + && ((state.current_block == block && state.is_running()) + || state.bg_jobs.iter().any(|j| { + j.lock() + .map(|g| g.block == block && !g.handle.is_finished()) + .unwrap_or(false) + })); + if still_running { + badge = Some(("⟳".to_string(), theme.accent)); + } + + let chevron = if collapsed { "▸" } else { "▾" }; + let mut header_children: Vec> = vec![ + View::new(Style { + size: Size { + width: length(14.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }) + .text_aligned(chevron.to_string(), 11.0, theme.fg_muted, Alignment::Start), + View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(16.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .text_aligned(header_text.clone(), 12.0, theme.accent, Alignment::Start), + ]; + // Chip de reprocess: alimenta el stdout de esta card como stdin del + // próximo comando. Sólo en cards con stdout. Hit-test innermost-wins: + // el chip gana el click sobre el header (que pliega el bloque). + let has_stdout = group + .iter() + .any(|l| l.kind == OutputKind::Stdout && l.stage.is_none()); + if has_stdout { + let armed = state.reprocess_source == Some(block); + let (fill, fg) = if armed { + (theme.accent, theme.bg_panel) + } else { + (theme.bg_input, theme.fg_muted) + }; + header_children.push( + View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(16.0_f32), + }, + padding: Rect { + left: length(5.0_f32), + right: length(5.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + ..Default::default() + }) + .fill(fill) + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::SetReprocess(block))) + .text_aligned("» stdin".to_string(), 10.0, fg, Alignment::Start), + ); + } + if let Some((btxt, bcolor)) = badge { + header_children.push( + View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(16.0_f32), + }, + ..Default::default() + }) + .text_aligned(btxt, 11.0, bcolor, Alignment::End), + ); + } + + let header = View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(HEADER_H), + }, + align_items: Some(AlignItems::Center), + padding: Rect { + left: length(6.0_f32), + right: length(8.0_f32), + top: length(2.0_f32), + bottom: length(2.0_f32), + }, + gap: Size { + width: length(6.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_input) + .radius(4.0) + .hover_fill(theme.bg_row_hover) + .on_click(lift(Msg::ToggleBlock(block))) + .children(header_children); + + let mut card_children: Vec> = vec![header]; + let mut child_h_sum = HEADER_H; + + // Fila de etapas de pipe (sólo si NO está colapsado y es un pipe). + if !collapsed { + if stage_lines.is_empty() { + // Sin captura en vivo (pipe vía `sh -c` o comando suelto): los + // chips re-ejecutan la línea hasta esa etapa. + if let Some(row) = pipe_stages_row::(&header_text, theme, lift) { + card_children.push(row); + child_h_sum += STAGES_H; + } + } else { + // Con captura (pipe directo + tee): los chips despliegan las + // líneas intermedias ya capturadas, sin re-ejecutar. + let (rows, h) = stage_capture_rows::( + &header_text, + &stage_lines, + block, + state, + theme, + lift, + ); + for r in rows { + card_children.push(r); + } + child_h_sum += h; + } + } + + if collapsed { + if !body.is_empty() { + card_children.push( + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(ROW_H), + }, + ..Default::default() + }) + .text_aligned( + format!("⋯ {} líneas", body.len()), + 11.0, + theme.fg_muted, + Alignment::Start, + ), + ); + child_h_sum += ROW_H; + } + } else { + for &line in &body { + card_children.push(render_output_line::(line, &state.cwd, theme, lift)); + child_h_sum += ROW_H; + } + } + + let n_children = card_children.len() as f32; + let card_h = CARD_PAD_V + child_h_sum + CARD_GAP * (n_children - 1.0); + + let view = View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + padding: Rect { + left: length(6.0_f32), + right: length(6.0_f32), + top: length(4.0_f32), + bottom: length(5.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(CARD_GAP), + }, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .radius(5.0) + .children(card_children); + + (view, card_h) +} + +/// Una "pieza" del partición de una línea: el texto, su color y el +/// kind de decoración (`None` = texto base, no clickable). El render +/// la convierte en `View`s; los tests verifican la partición sin +/// pintar. +#[derive(Debug, Clone)] +pub(crate) struct LinePiece { + pub(crate) text: String, + pub(crate) color: llimphi_ui::llimphi_raster::peniko::Color, + pub(crate) deco: Option, +} + +/// Divide `text` en piezas según `decorations`. Las piezas no decoradas +/// llevan `color = base` y `deco = None`. Las decoradas llevan el +/// color según el kind y `deco = Some(kind.clone())`. +pub(crate) fn partition_line( + text: &str, + decorations: &[shuma_line::Decoration], + base: llimphi_ui::llimphi_raster::peniko::Color, + theme: &Theme, +) -> Vec { + use shuma_line::DecorationKind as Dk; + let mut out: Vec = Vec::new(); + let mut cursor = 0usize; + for d in decorations { + if d.start < cursor || d.end > text.len() || d.start >= d.end { + continue; + } + if d.start > cursor { + out.push(LinePiece { + text: text[cursor..d.start].to_string(), + color: base, + deco: None, + }); + } + let color = match &d.kind { + Dk::GitSha(_) => theme.fg_muted, + // El resto va al accent — paths, urls, grep refs, issue refs, + // box-drawing. Sin underline (Llimphi aún no lo soporta). + _ => theme.accent, + }; + out.push(LinePiece { + text: text[d.start..d.end].to_string(), + color, + deco: Some(d.kind.clone()), + }); + cursor = d.end; + } + if cursor < text.len() { + out.push(LinePiece { + text: text[cursor..].to_string(), + color: base, + deco: None, + }); + } + out +} + +/// Pinta una línea del output. Para Stdout/Stderr aplica +/// `shuma_line::decorate_line`: pinta cada span con su color y, si la +/// decoración es accionable (`Path`/`Url`/`GrepRef`/`GitSha`), agrega +/// un `on_click` que dispara `Msg::OpenDecoration`. Para Prompt/Notice +/// usa el atajo `text_aligned` plano. +pub(crate) fn render_output_line( + line: &OutputLine, + cwd: &std::path::Path, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> View { + let line_style = Style { + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + ..Default::default() + }; + + match line.kind { + OutputKind::Prompt => View::new(line_style).text_aligned( + line.text.clone(), + 12.0, + theme.accent, + Alignment::Start, + ), + OutputKind::Notice => View::new(line_style).text_aligned( + line.text.clone(), + 12.0, + theme.fg_muted, + Alignment::Start, + ), + OutputKind::Stdout | OutputKind::Stderr => { + let base = if matches!(line.kind, OutputKind::Stderr) { + theme.fg_destructive + } else { + theme.fg_text + }; + let decorations = shuma_line::decorate_line(&line.text, cwd); + // Atajo: si no hubo decoraciones, una sola text_aligned alcanza. + if decorations.is_empty() { + return View::new(line_style).text_aligned( + line.text.clone(), + 12.0, + base, + Alignment::Start, + ); + } + let children = + build_span_children::(&line.text, &decorations, base, theme, lift); + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(16.0_f32), + }, + align_items: Some(AlignItems::Center), + ..Default::default() + }) + .children(children) + } + } +} + +/// Convierte las piezas en una lista de `View`s. Las accionables +/// (Path/Url/GrepRef/GitSha) llevan `on_click`. +/// Mapea la categoría semántica de `shuma-line` al icono vectorial del +/// set canónico `llimphi-icons`. Los iconos monocromos son más gruesos +/// que los emoji (un solo `code` para todos los lenguajes, un `file_text` +/// para todos los documentos) — la pérdida de granularidad es el precio +/// de no depender de fuentes de emoji del sistema. +fn kind_icon(kind: shuma_line::FileKind) -> llimphi_icons::Icon { + use llimphi_icons::Icon; + use shuma_line::FileKind as K; + match kind { + K::Folder => Icon::Folder, + K::Symlink => Icon::Link, + K::Image => Icon::Image, + K::Audio => Icon::Music, + K::Video => Icon::Film, + K::Archive => Icon::Archive, + K::Document => Icon::FileText, + K::Code => Icon::Code, + K::Data => Icon::Code, + K::Font => Icon::Font, + K::Executable => Icon::Settings, + K::Generic => Icon::File, + } +} + +pub(crate) fn build_span_children( + text: &str, + decorations: &[shuma_line::Decoration], + base: llimphi_ui::llimphi_raster::peniko::Color, + theme: &Theme, + lift: &(impl Fn(Msg) -> HostMsg + Clone + Send + Sync + 'static), +) -> Vec> { + use shuma_line::DecorationKind as Dk; + let pieces = partition_line(text, decorations, base, theme); + let mut out: Vec> = Vec::with_capacity(pieces.len()); + for p in pieces { + if p.text.is_empty() { + continue; + } + let actionable = matches!( + p.deco, + Some(Dk::Path { .. } | Dk::Url(_) | Dk::GrepRef { .. } | Dk::GitSha(_)) + ); + // Texto del span. Para paths le anteponemos un icono vectorial por + // tipo (no emoji): así un `ls` se lee como un explorador de + // archivos (carpeta/imagen/código/…) sin depender de fuentes de + // emoji del sistema. + let text_view: View = View::new(Style { + ..Default::default() + }) + .text_aligned(p.text.clone(), 12.0, p.color, Alignment::Start); + let mut span_view: View = match &p.deco { + Some(Dk::Path { + abs, + is_dir, + is_executable, + is_symlink, + }) => { + let kind = shuma_line::file_kind(abs, *is_dir, *is_executable, *is_symlink); + let icon_box: View = View::new(Style { + size: Size { + width: length(13.0_f32), + height: length(13.0_f32), + }, + flex_shrink: 0.0, + ..Default::default() + }) + .children(vec![llimphi_icons::icon_view( + kind_icon(kind), + p.color, + 1.6, + )]); + View::new(Style { + flex_direction: FlexDirection::Row, + align_items: Some(AlignItems::Center), + gap: Size { + width: length(5.0_f32), + height: length(0.0_f32), + }, + ..Default::default() + }) + .children(vec![icon_box, text_view]) + } + _ => text_view, + }; + if let (true, Some(kind)) = (actionable, p.deco) { + let l = lift.clone(); + // Feedback de hover: el span se resalta al pasar el cursor — + // un `ls` se siente como un explorador donde cada archivo + // "responde". (Llimphi no expone cursor-icon del SO; el + // realce es el afford idiomático, igual que en tree/button.) + span_view = span_view + .radius(3.0) + .hover_fill(theme.bg_row_hover) + .on_click(l(Msg::OpenDecoration(kind))); + } + out.push(span_view); + } + out +} + +pub(crate) fn pretty_path(p: &std::path::Path) -> String { + let full = p.display().to_string(); + if let Ok(home) = std::env::var("HOME") { + if full == home { + return "~".into(); + } + if let Some(rest) = full.strip_prefix(&format!("{home}/")) { + return format!("~/{rest}"); + } + } + full +} diff --git a/02_ruway/shuma/sandbox/shuma-module/Cargo.toml b/02_ruway/shuma/sandbox/shuma-module/Cargo.toml new file mode 100644 index 0000000..43d1f75 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shuma-module" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-module — contrato compartido para módulos de shuma-shell-llimphi: tipos para slots de tab, monitor y shortcut + selección de origen (local o remoto). El registry concreto vive en cada host." + +[dependencies] +serde = { workspace = true } + +[dev-dependencies] +toml = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-module/LEEME.md b/02_ruway/shuma/sandbox/shuma-module/LEEME.md new file mode 100644 index 0000000..88b6d42 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module/LEEME.md @@ -0,0 +1,9 @@ +# shuma-module + +> Trait módulo del chasis de [shuma](../../README.md). + +`Module` define un slot UI: `render`, `update`, `id`. Permite componer shell + commandbar + launcher + matilda en una sola ventana. + +## Deps + +- [`llimphi-ui`](../../../llimphi/), [`shuma-core`](../shuma-core/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module/README.md b/02_ruway/shuma/sandbox/shuma-module/README.md new file mode 100644 index 0000000..811a6dc --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module/README.md @@ -0,0 +1,9 @@ +# shuma-module + +> Chassis module trait of [shuma](../../README.md). + +`Module` defines a UI slot: `render`, `update`, `id`. Lets you compose shell + commandbar + launcher + matilda in a single window. + +## Deps + +- [`llimphi-ui`](../../../llimphi/), [`shuma-core`](../shuma-core/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-module/src/lib.rs b/02_ruway/shuma/sandbox/shuma-module/src/lib.rs new file mode 100644 index 0000000..0ac8ba4 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-module/src/lib.rs @@ -0,0 +1,572 @@ +//! `shuma-module` — contrato de los módulos enchufables a `shuma-shell-llimphi`. +//! +//! Un módulo aporta hasta tres cosas a la ventana del shell: +//! +//! 1. **Tab principal** — una vista propia, ocupando el panel central +//! cuando su tab está activo. +//! 2. **Monitores** — curvas pequeñas que viven en el stack del panel +//! derecho, junto a CPU/MEM. +//! 3. **Shortcuts** — botones de la toolbar de la app-header que disparan +//! una acción del módulo o publican un comando al shell. +//! +//! El contrato es **estructural**, no un trait dinámico: cada módulo es +//! un crate que define su propio `State`/`Msg`/`update`/`view` y expone +//! una `pub fn make(host: ModuleHost) -> Box<...>`. El host (el binario +//! `shuma-shell-llimphi`) tiene un enum `ShellMsg` con una variante por +//! módulo conocido y los enlaza al compilar. +//! +//! Aquí sólo viven los **tipos compartidos**: +//! +//! - [`Source`] — local o remoto (con credenciales SSH). +//! - [`ModuleConfig`] — entrada de un `[[modules]]` del `shumarc.toml`. +//! - [`MonitorSpec`] — descriptor declarativo de un monitor (label, +//! color, capacidad de historial, frecuencia de sampling). +//! - [`Sample`] — un punto de la curva. +//! - [`ShortcutSpec`] — descriptor declarativo de un botón de toolbar. +//! - [`ShortcutAction`] — qué hace al pulsarse. +//! - [`Placement`] — en qué slot del chasis vive el módulo (TopBar, +//! Main, BottomBar, DrawerTab). +//! - [`DrawerTrigger`] — qué dispara la apertura del drawer Quake. +//! +//! El módulo no depende de `llimphi-ui` desde este crate; el host le +//! pasa el `Theme` y el módulo construye el `View` con un `lift` +//! (cierre que mapea su `Msg` propio al `ShellMsg`). El lift cierra la +//! brecha de "no hay `View::map`" sin pagar el costo de un trait +//! object con `Box`. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +/// Identificador único de un módulo dentro de una sesión. Se compara +/// case-sensitive contra los `id` de los `[[modules]]` del shumarc. +pub type ModuleId = &'static str; + +/// Origen contra el cual opera un módulo: `Local` actúa sobre esta +/// máquina, `Daemon` lo hace vía `shuma-daemon` por Unix socket, +/// `DaemonTcp` igual pero por TCP autenticado (Noise XK), `Remote` +/// SSH para módulos que aún no hablan el protocolo del daemon (matilda). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Source { + /// Esta máquina, ejecución directa (`shuma-exec::run`). + Local, + /// Esta máquina pero los comandos van por el daemon — Unix socket. + /// `socket` es opcional: cuando es `None` se usa `default_socket_path()`. + Daemon { + #[serde(default)] + socket: Option, + #[serde(default)] + label: Option, + }, + /// Daemon en otro host, conectado por TCP con handshake Noise XK. + /// `server_pub_hex` es la pubkey del server (estilo `known_hosts`). + DaemonTcp { + addr: String, + server_pub_hex: String, + #[serde(default)] + label: Option, + }, + /// Servidor remoto vía SSH. `host` y `user` son obligatorios; el + /// método de autenticación se resuelve aparte (clave por defecto o + /// password de un keystore — no se serializa aquí en claro). + Remote { + host: String, + user: String, + /// Puerto SSH; default 22. + #[serde(default = "default_ssh_port")] + port: u16, + /// Etiqueta amigable para mostrar en la UI; default = `user@host`. + #[serde(default)] + label: Option, + }, +} + +fn default_ssh_port() -> u16 { + 22 +} + +impl Source { + /// Etiqueta corta para la UI (tab, monitor, etc.). + pub fn label(&self) -> String { + match self { + Source::Local => "local".into(), + Source::Daemon { label: Some(l), .. } => l.clone(), + Source::Daemon { socket: Some(p), .. } => format!("daemon:{}", p.display()), + Source::Daemon { .. } => "daemon".into(), + Source::DaemonTcp { label: Some(l), .. } => l.clone(), + Source::DaemonTcp { addr, .. } => format!("daemon@{addr}"), + Source::Remote { label: Some(l), .. } => l.clone(), + Source::Remote { host, user, .. } => format!("{user}@{host}"), + } + } + + /// `true` si el origen es remoto (SSH o DaemonTcp). + pub fn is_remote(&self) -> bool { + matches!(self, Source::Remote { .. } | Source::DaemonTcp { .. }) + } +} + +impl Default for Source { + fn default() -> Self { + Source::Local + } +} + +/// Configuración declarativa de **una instancia** de módulo, tal como +/// aparece en `shumarc.toml`: +/// +/// ```toml +/// [[modules]] +/// id = "matilda" # qué módulo activar (debe estar enlazado en el host) +/// source = { kind = "local" } +/// +/// [[modules]] +/// id = "matilda" +/// source = { kind = "remote", host = "edge-1.example", user = "deploy" } +/// label = "edge-1" # opcional, override del label del Source +/// options = { inventory = "/etc/matilda/edge-1.json" } +/// ``` +/// +/// `options` es un valor TOML opaco que cada módulo parsea a su gusto; +/// el host no lo interpreta. Si el módulo no enlistado en el host +/// aparece aquí, se ignora con un warning (no crash) — un shumarc no +/// debe romper el arranque del shell por un módulo desconocido. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ModuleConfig { + /// `id` que el host usa para enrutar (igual a `Module::id`). + pub id: String, + /// Origen contra el cual opera esta instancia. + #[serde(default)] + pub source: Source, + /// Override del label de la tab/monitor. `None` = usa el default + /// que decida el módulo (típicamente `Source::label`). + #[serde(default)] + pub label: Option, + /// Opciones específicas del módulo (parseo delegado al módulo). + /// Se mantiene como string TOML para evitar acoplar este crate a + /// `toml::Value` — el módulo decide cómo deserializar. + #[serde(default)] + pub options: Option, +} + +impl ModuleConfig { + /// Construye una instancia con `id` + `source` y resto en defaults. + /// Útil en tests y para registrar módulos sin shumarc (built-ins). + pub fn new(id: impl Into, source: Source) -> Self { + Self { + id: id.into(), + source, + label: None, + options: None, + } + } + + /// Etiqueta efectiva: `label` si está, si no la del `Source`. + pub fn effective_label(&self) -> String { + self.label.clone().unwrap_or_else(|| self.source.label()) + } +} + +/// En qué slot del chasis vive un módulo. El chasis dispone de cuatro +/// slots fijos que el shumarc puebla: +/// +/// ```text +/// ┌─────────────────────────────────────────┐ +/// │ TopBar (1 módulo: launcher) │ +/// ├─────────────────────────────────────────┤ +/// │ │ +/// │ Main (1 módulo focal — │ +/// │ matilda, editor, etc.) │ +/// │ │ +/// ├─────────────────────────────────────────┤ +/// │ ▲ Drawer Quake (overlay, oculto por │ +/// │ default; N módulos DrawerTab) │ +/// ├─────────────────────────────────────────┤ +/// │ BottomBar (1 módulo: command-bar) │ +/// └─────────────────────────────────────────┘ +/// ``` +/// +/// Cada módulo declara su `Placement` *preferido*; el shumarc puede +/// sobreescribirlo. Un módulo puede ser válido en más de un slot +/// (p. ej. `shell` puede ir como `DrawerTab` o como `Main`) — esos +/// casos se modelan con instancias separadas en el shumarc, no con +/// "multi-placement" en el módulo. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Placement { + /// Barra superior fija (launcher de apps / shortcuts). + TopBar, + /// Área principal de la ventana. **Único** por sesión: el módulo + /// "foco". El drawer aparece encima como overlay sin reemplazarlo. + #[default] + Main, + /// Barra inferior fija (command bar — input de doble modo + /// launcher/shell). Auto-escondible según la `BarBehavior`. + BottomBar, + /// Tab del drawer Quake. **N** módulos pueden vivir aquí; el + /// usuario navega entre ellos con la tira de tabs del drawer. + DrawerTab, +} + +impl Placement { + /// `true` si el slot acepta múltiples instancias simultáneas. + /// Sólo `DrawerTab` lo es; los demás son únicos. + pub fn allows_multiple(self) -> bool { + matches!(self, Placement::DrawerTab) + } +} + +/// Comportamiento de visibilidad de una barra (top o bottom). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BarBehavior { + /// Siempre visible. Ocupa su alto en el layout permanentemente. + #[default] + Fixed, + /// Visible al hover o foco; oculta cuando el cursor sale (con un + /// delay corto). Cuando está oculta, no roba alto al `Main`. + Autohide, +} + +/// Qué dispara la apertura/cierre del drawer Quake. Múltiples triggers +/// se pueden activar simultáneamente (tecla + hover); cualquiera abre. +/// El cierre es por la inversa: salir del hover, soltar la tecla +/// (toggle) o pulsar `Esc`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DrawerTrigger { + /// Tecla global que togglea el drawer. `None` = sin tecla. Formato + /// libre (lo parsea el chasis): "F12", "Super+grave", etc. + #[serde(default)] + pub key: Option, + /// `true` = pasar el mouse sobre la command bar abre el drawer. + #[serde(default)] + pub hover: bool, + /// Alto del drawer como fracción de la ventana (0.0..=1.0). + /// `0.4` típico para drawer Quake. + #[serde(default = "default_drawer_height")] + pub height_fraction: f32, +} + +fn default_drawer_height() -> f32 { + 0.4 +} + +impl Default for DrawerTrigger { + /// Default razonable: tecla `F12` + hover off + 40% de alto. + fn default() -> Self { + Self { + key: Some("F12".into()), + hover: false, + height_fraction: default_drawer_height(), + } + } +} + +/// Una muestra puntual de un monitor — un valor numérico (porcentaje, +/// recuento, latencia, lo que sea) más un texto corto para mostrar +/// junto al label. El módulo decide la unidad y el formato. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Sample { + /// Valor numérico; típicamente `0.0..=100.0` para porcentajes pero + /// el módulo puede usar cualquier rango. La curva escala al min/max + /// de su buffer. + pub value: f32, + /// Texto secundario; típicamente "42%" o "3 pendientes". Vacío si + /// el monitor sólo dibuja la curva sin valor numérico al lado. + pub display: String, +} + +impl Sample { + pub fn new(value: f32, display: impl Into) -> Self { + Self { + value, + display: display.into(), + } + } +} + +/// Color RGB en `0..=255` por canal. Lo deja como ints para no depender +/// de `peniko::Color` en este crate (el host lo convierte al pintar). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Rgb { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Rgb { + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } +} + +/// Descriptor declarativo de **un monitor**. El host: +/// +/// 1. Crea el slot en el panel derecho con el `label` + `accent`. +/// 2. Llama a `sampler()` cada `period` (típicamente 1s). +/// 3. Mantiene un historial de `history_capacity` muestras. +/// 4. Dibuja la curva (línea finita normalizada al min/max del buffer) +/// y al lado el `Sample::display` más reciente. +/// +/// El módulo no toca el frame: sólo provee datos. Si `sampler()` es +/// caro, el módulo es libre de delegar a un hilo y devolver el último +/// snapshot cacheado — el host no impone política. +pub struct MonitorSpec { + /// `id` único dentro del módulo (no global). El host antepone el id + /// del módulo para evitar colisiones. + pub id: &'static str, + /// Texto que se muestra arriba de la curva ("docker", "drift", …). + pub label: String, + /// Color de la curva. `Rgb` para no depender de `peniko` aquí. + pub accent: Rgb, + /// Cuántas muestras guarda el ring buffer del historial. + pub history_capacity: usize, + /// Cada cuánto se muestrea (segundos). El host puede agregar + /// jitter para evitar que todos los monitores caigan en el mismo + /// tick. + pub period_secs: f32, + /// Closure que produce la muestra actual. Debe ser `Send + Sync` + /// para que el host la pueda invocar desde un hilo de polling. + pub sampler: Box Sample + Send + Sync>, +} + +impl std::fmt::Debug for MonitorSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MonitorSpec") + .field("id", &self.id) + .field("label", &self.label) + .field("accent", &self.accent) + .field("history_capacity", &self.history_capacity) + .field("period_secs", &self.period_secs) + .field("sampler", &"") + .finish() + } +} + +/// Qué hace un shortcut al pulsarse. La granularidad busca cubrir el +/// 80% sin exponer el `Msg` del módulo al host: +/// +/// - `Command` — manda una línea al input del shell (como si el usuario +/// la hubiera tipeado y enter). Útil para integrar comandos arbitrarios. +/// - `FocusTab` — cambia la tab activa al módulo indicado. +/// - `ModuleAction` — opaco al host: el módulo lo recibe en su `update` +/// con esta `action_id` y decide. Es la vía para "Aplicar plan", +/// "Refrescar", etc. específicas del módulo. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ShortcutAction { + /// Inyectar una línea en el input del shell. + Command { line: String }, + /// Cambiar la tab activa al módulo `target` (su `ModuleId`). + FocusTab { target: String }, + /// Acción opaca, enrutada al módulo emisor. + ModuleAction { action_id: &'static str }, +} + +/// Descriptor declarativo de **un shortcut** de la toolbar. El host: +/// +/// 1. Inserta un botón con el `label` en la app-header. +/// 2. Si `hint` está, lo muestra como tooltip. +/// 3. Al click, ejecuta el `action` según su variante. +#[derive(Debug, Clone, PartialEq)] +pub struct ShortcutSpec { + /// Texto del botón ("Plan", "Apply", "Discover", "Logs", …). + pub label: String, + /// Tooltip / texto secundario. Opcional. + pub hint: Option, + /// Qué hace al pulsarse. + pub action: ShortcutAction, +} + +impl ShortcutSpec { + pub fn command(label: impl Into, line: impl Into) -> Self { + Self { + label: label.into(), + hint: None, + action: ShortcutAction::Command { line: line.into() }, + } + } + + pub fn module_action(label: impl Into, action_id: &'static str) -> Self { + Self { + label: label.into(), + hint: None, + action: ShortcutAction::ModuleAction { action_id }, + } + } + + pub fn focus_tab(label: impl Into, target: impl Into) -> Self { + Self { + label: label.into(), + hint: None, + action: ShortcutAction::FocusTab { target: target.into() }, + } + } + + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } +} + +/// Catálogo de las contribuciones declarativas (sin View) de un módulo. +/// El módulo lo produce con su `State` actual y el host lo consume para +/// poblar el panel derecho y la toolbar. La vista del tab va aparte +/// porque depende del `ShellMsg` del host (no encaja como `dyn`). +#[derive(Debug)] +pub struct ModuleContributions { + pub monitors: Vec, + pub shortcuts: Vec, +} + +impl ModuleContributions { + pub fn empty() -> Self { + Self { + monitors: Vec::new(), + shortcuts: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_default_is_local() { + assert_eq!(Source::default(), Source::Local); + assert!(!Source::default().is_remote()); + } + + #[test] + fn remote_source_label_falls_back_to_user_at_host() { + let s = Source::Remote { + host: "srv".into(), + user: "ops".into(), + port: 22, + label: None, + }; + assert_eq!(s.label(), "ops@srv"); + assert!(s.is_remote()); + } + + #[test] + fn remote_source_label_uses_override_when_set() { + let s = Source::Remote { + host: "srv".into(), + user: "ops".into(), + port: 22, + label: Some("edge".into()), + }; + assert_eq!(s.label(), "edge"); + } + + #[test] + fn module_config_effective_label_prefers_explicit() { + let mut c = ModuleConfig::new("matilda", Source::Local); + assert_eq!(c.effective_label(), "local"); + c.label = Some("Servidores".into()); + assert_eq!(c.effective_label(), "Servidores"); + } + + #[test] + fn shortcut_constructors() { + let cmd = ShortcutSpec::command("ls", "ls -la").with_hint("listar"); + assert_eq!(cmd.label, "ls"); + assert_eq!(cmd.hint.as_deref(), Some("listar")); + match cmd.action { + ShortcutAction::Command { line } => assert_eq!(line, "ls -la"), + _ => panic!("expected Command"), + } + + let act = ShortcutSpec::module_action("Apply", "matilda.apply"); + match act.action { + ShortcutAction::ModuleAction { action_id } => assert_eq!(action_id, "matilda.apply"), + _ => panic!("expected ModuleAction"), + } + + let foc = ShortcutSpec::focus_tab("→ Matilda", "matilda"); + match foc.action { + ShortcutAction::FocusTab { target } => assert_eq!(target, "matilda"), + _ => panic!("expected FocusTab"), + } + } + + #[test] + fn monitor_spec_holds_a_callable_sampler() { + let m = MonitorSpec { + id: "test", + label: "Test".into(), + accent: Rgb::new(255, 100, 0), + history_capacity: 60, + period_secs: 1.0, + sampler: Box::new(|| Sample::new(42.0, "42%")), + }; + let s = (m.sampler)(); + assert_eq!(s.value, 42.0); + assert_eq!(s.display, "42%"); + } + + #[test] + fn placement_default_is_main() { + assert_eq!(Placement::default(), Placement::Main); + } + + #[test] + fn only_drawer_tab_allows_multiple_instances() { + assert!(Placement::DrawerTab.allows_multiple()); + assert!(!Placement::TopBar.allows_multiple()); + assert!(!Placement::BottomBar.allows_multiple()); + assert!(!Placement::Main.allows_multiple()); + } + + #[test] + fn placement_round_trips_snake_case_toml() { + // Sanity check del rename_all snake_case en serde. + let p: Placement = toml::from_str("v = \"top_bar\"\n") + .ok() + .and_then(|t: toml::Table| t.get("v").cloned()) + .and_then(|v| v.try_into().ok()) + .unwrap(); + assert_eq!(p, Placement::TopBar); + let p: Placement = toml::from_str("v = \"drawer_tab\"\n") + .ok() + .and_then(|t: toml::Table| t.get("v").cloned()) + .and_then(|v| v.try_into().ok()) + .unwrap(); + assert_eq!(p, Placement::DrawerTab); + } + + #[test] + fn drawer_trigger_default_is_f12_no_hover() { + let d = DrawerTrigger::default(); + assert_eq!(d.key.as_deref(), Some("F12")); + assert!(!d.hover); + assert!((d.height_fraction - 0.4).abs() < 1e-6); + } + + #[test] + fn bar_behavior_default_is_fixed() { + assert_eq!(BarBehavior::default(), BarBehavior::Fixed); + } + + #[test] + fn module_config_round_trips_toml() { + let c = ModuleConfig { + id: "matilda".into(), + source: Source::Remote { + host: "srv".into(), + user: "ops".into(), + port: 2222, + label: None, + }, + label: Some("Edge 1".into()), + options: Some("inventory = \"/etc/matilda/inv.json\"".into()), + }; + // Round-trip por toml: el shumarc usa esto para serializar/parsear. + let text = toml::to_string(&c).unwrap(); + let back: ModuleConfig = toml::from_str(&text).unwrap(); + assert_eq!(c, back); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-protocol/Cargo.toml b/02_ruway/shuma/sandbox/shuma-protocol/Cargo.toml new file mode 100644 index 0000000..3661203 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-protocol/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "shuma-protocol" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Wire protocol entre shipote-daemon y clientes (cli/gui). Postcard length-prefixed sobre Unix socket." + +[dependencies] +shuma-card = { path = "../shuma-card" } +card-core = { workspace = true } +serde = { workspace = true } +postcard = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +ulid = { workspace = true } +nix = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-protocol/LEEME.md b/02_ruway/shuma/sandbox/shuma-protocol/LEEME.md new file mode 100644 index 0000000..5aed9ef --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-protocol/LEEME.md @@ -0,0 +1,9 @@ +# shuma-protocol + +> Protocolo wire (reemplazo de SSH/mosh) de [shuma](../../README.md). + +Binario sobre TCP/TLS (con `rustls`). Reconnect resilient, multiplexing nativo, transferencia de archivos integrada. **No requiere SSH server**: el daemon habla este protocolo. + +## Deps + +- `serde`, `postcard`, `rustls` diff --git a/02_ruway/shuma/sandbox/shuma-protocol/README.md b/02_ruway/shuma/sandbox/shuma-protocol/README.md new file mode 100644 index 0000000..6c80381 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-protocol/README.md @@ -0,0 +1,9 @@ +# shuma-protocol + +> Wire protocol (SSH/mosh replacement) of [shuma](../../README.md). + +Binary over TCP/TLS (with `rustls`). Resilient reconnect, native multiplexing, integrated file transfer. **No SSH server required**: the daemon speaks this protocol. + +## Deps + +- `serde`, `postcard`, `rustls` diff --git a/02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs b/02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs new file mode 100644 index 0000000..ef26ac9 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-protocol/src/lib.rs @@ -0,0 +1,644 @@ +//! `shuma-protocol` — wire daemon ↔ cliente (cli/gui). +//! +//! Framing: u32 BE length-prefix + payload postcard. Mismo patrón que +//! `ente-bus`/`brahman-handshake` para que clientes existentes compartan +//! reader/writer helpers si quieren. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use shuma_card::{PipelineSpec, WorkspaceId, WorkspaceSpec}; +use std::path::PathBuf; +use thiserror::Error; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use ulid::Ulid; + +pub const DEFAULT_SOCK_NAME: &str = "shuma.sock"; +pub const MAX_FRAME: usize = 1 << 20; + +fn default_grace_ms() -> u64 { + 1000 +} + +// ===================================================================== +// Mensajes +// ===================================================================== + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Request { + /// Health-check. + Ping, + + /// Health endpoint estructurado: versión + uptime + counts. + Health, + + /// Crear un workspace nuevo. + WorkspaceCreate { spec: WorkspaceSpec }, + + /// Listar todos los workspaces vivos. + WorkspaceList, + + /// Detener un workspace y reapear sus comandos. `grace_ms`: tiempo + /// que se espera tras SIGTERM antes de SIGKILL. 0 = SIGKILL inmediato. + WorkspaceStop { + id: WorkspaceId, + #[serde(default = "default_grace_ms")] + grace_ms: u64, + }, + + /// Ejecutar un comando one-shot dentro de un workspace existente. + Run { + workspace: WorkspaceId, + exec: String, + argv: Vec, + envp: Vec<(String, String)>, + /// Si `true` y el comando muere con exit_status != 0, el reaper + /// lo relaunch con backoff exponencial. + #[serde(default)] + restart_on_failure: bool, + }, + + /// Lanzar un Pipeline completo dentro de un workspace. + PipelineRun { + spec: PipelineSpec, + /// Si `true`, el daemon interpone un tap entre productor y + /// consumidor de cada FlowEdge, sampleando los primeros bytes + /// y discerniendo el TypeRef. + tap: bool, + /// Variables para sustitución `${KEY}` en strings del spec + /// antes de spawn (templating). + #[serde(default)] + vars: std::collections::BTreeMap, + }, + + /// Discernir un buffer ad-hoc (sin workspace). Útil para `shuma discern `. + Discern { sample: Vec, hint_path: Option }, + + /// Capacidades runtime del kernel/proceso del daemon. + Capabilities, + + /// Listar comandos vivos+pasados de un workspace. + CommandList { workspace: shuma_card::WorkspaceId }, + + /// Tail del log capturado para un comando. + CommandLogs { + workspace: shuma_card::WorkspaceId, + command: Ulid, + tail_bytes: usize, + /// "stdout" | "stderr" | "both" (default "both" si vacío). + stream: String, + }, + + /// Guardar (o reemplazar) un PipelineSpec bajo un nombre. + PipelineSave { name: String, spec: PipelineSpec }, + + /// Listar nombres de pipelines guardados. + PipelineSavedList, + + /// Eliminar un pipeline guardado. + PipelineDrop { name: String }, + + /// Ejecutar un pipeline guardado. + PipelineRunSaved { + name: String, + tap: bool, + #[serde(default)] + vars: std::collections::BTreeMap, + }, + + /// Resource accounting de un workspace. + WorkspaceStats { workspace: shuma_card::WorkspaceId }, + + /// Reporte de quotas (rlimits declarados vs uso actual). + WorkspaceQuota { workspace: shuma_card::WorkspaceId }, + + /// History de samples del workspace (server-side). Sobrevive + /// restart del shell. `tail`: cantidad de samples desde el final + /// (0 = todo). + WorkspaceStatsHistory { + workspace: shuma_card::WorkspaceId, + tail: usize, + }, + + /// Resumen completo de un workspace: stats + quota + commands + + /// flow sockets en una sola roundtrip. Reduce N×4 requests del + /// shell a N×1. + WorkspaceFullSummary { workspace: shuma_card::WorkspaceId }, + + /// Detener selectivamente los comandos de un pipeline (no el workspace + /// entero). `grace_ms`: SIGTERM → wait → SIGKILL. + PipelineStop { + pipeline: Ulid, + #[serde(default = "default_grace_ms")] + grace_ms: u64, + }, + + /// Listar pipelines activos con sus flow channels (data plane). + FlowList, + + /// Throughput por flow socket: bytes_total + bytes_per_sec. + FlowThroughput, + + /// Cerrar el data plane de un pipeline (drop sockets + canales). + FlowDrop { pipeline: Ulid }, + + /// Ejecutar un comando "shell-like" (sin workspace/cgroups) y + /// recibir su salida en **streaming** sobre la misma conexión. + /// + /// Modelo: + /// 1. El cliente envía `ExecStream`. + /// 2. El daemon spawnea el proceso (vía `shuma-exec`) y, sobre la + /// misma conexión, escribe una secuencia de `Response::Exec*` + /// terminada por `ExecExited` o `ExecFailed`. + /// 3. Tras el evento terminal, la conexión vuelve a modo + /// request/response normal: el cliente puede mandar otra + /// `Request` y el daemon contesta una `Response` por cada una. + /// + /// Para abortar a mitad de ejecución, el cliente cierra la conexión; + /// el daemon detecta el EOF al intentar escribir el próximo frame y + /// mata el proceso. Es la convención SSH/PTY. + ExecStream { + /// Directorio de trabajo del proceso. + cwd: String, + /// Modo de ejecución: directo (pipe de etapas) o vía shell. + exec: ExecKind, + /// Tope de captura por proceso en bytes; `0` = sin tope. + #[serde(default)] + capture_limit_bytes: usize, + /// Texto a alimentar por stdin — para reprocesar salidas previas. + #[serde(default)] + stdin_data: Option, + /// Captura por etapa (tee): en un pipe `Direct`, el daemon + /// intercepta el stdout de cada etapa intermedia y lo emite como + /// [`Response::ExecStageStdout`] además de alimentar a la + /// siguiente. `#[serde(default)]` => clientes viejos lo omiten y + /// el daemon corre sin tee (comportamiento previo). + #[serde(default)] + capture_stages: bool, + }, + + /// Abre un **PTY remoto**: el daemon spawnea `program args` bajo un + /// pseudo-terminal de `rows`×`cols` y la conexión entra en modo + /// **full-duplex** dedicado: + /// - server→cliente: una secuencia de [`Response::ExecBytes`] (la + /// salida cruda del terminal, con escapes ANSI) terminada por + /// `ExecExited`/`ExecFailed`. + /// - cliente→server: cero o más [`Request::PtyInput`] (teclas) y + /// [`Request::PtyResize`] (cambios de tamaño), hasta que cierra. + /// + /// El cliente aborta cerrando la conexión (EOF) — convención SSH/PTY, + /// igual que [`Request::ExecStream`]. A diferencia de `ExecStream`, la + /// dirección cliente→server lleva frames reales, no sólo el cierre. + ExecPty { + cwd: String, + program: String, + args: Vec, + rows: u16, + cols: u16, + }, + /// (Sólo dentro de un `ExecPty`) bytes de stdin del usuario hacia el + /// PTY remoto — teclas, paste, etc. + PtyInput { bytes: Vec }, + /// (Sólo dentro de un `ExecPty`) la ventana del cliente cambió de + /// tamaño; el daemon reescala el PTY (`TIOCSWINSZ`). + PtyResize { rows: u16, cols: u16 }, +} + +/// Cómo ejecutar — variante serializable paralela a `shuma_exec::Exec`. +/// Se mantiene aquí para que `shuma-protocol` no dependa de `shuma-exec` +/// (que es un crate sync para spawning). El daemon traduce entre ambas. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ExecKind { + /// Delega a un shell externo (`program -c ""`). + Shell { line: String, program: String }, + /// Directo — daemon lanza y conecta cada etapa del pipe. + Direct { stages: Vec }, +} + +/// Una etapa del pipe en ejecución directa. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecStage { + pub program: String, + pub args: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Response { + Pong, + + Health { + version: String, + uptime_ms: u64, + alive_workspaces: u32, + alive_commands: u32, + alive_pipelines: u32, + active_flows: u32, + dirty: bool, + }, + + WorkspaceCreated { + id: WorkspaceId, + warnings: Vec, + }, + + WorkspaceList { + items: Vec, + }, + + WorkspaceStopped { + id: WorkspaceId, + reaped: u32, + }, + + RunStarted { + workspace: WorkspaceId, + command_id: Ulid, + pid: i32, + }, + + PipelineStarted { + pipeline: Ulid, + command_pids: Vec<(String, i32)>, + /// Discernments por edge cuando tap=true. Vacío sin tap. + edges: Vec, + }, + + Discernment { + ty: String, + confidence: f32, + mime: Option, + lens: Option, + }, + + Capabilities { + kernel_version: (u32, u32, u32), + user_ns: String, + cgroup_v2: String, + cgroup_delegated: bool, + has_cap_sys_admin: bool, + }, + + CommandList { + items: Vec, + }, + + CommandLogs { + bytes: Vec, + }, + + PipelineSaved { + name: String, + }, + + PipelineSavedList { + names: Vec, + }, + + PipelineDropped { + name: String, + existed: bool, + }, + + PipelineStopped { + pipeline: Ulid, + reaped: u32, + }, + + WorkspaceStats { + info: WorkspaceStatsInfo, + }, + + WorkspaceQuota { + info: QuotaReportInfo, + }, + + WorkspaceStatsHistory { + samples: Vec, + }, + + WorkspaceFullSummary { + stats: WorkspaceStatsInfo, + quota: QuotaReportInfo, + commands: Vec, + flow_sockets: Vec, + }, + + FlowList { + items: Vec, + }, + + FlowThroughput { + items: Vec, + }, + + FlowDropped { + pipeline: Ulid, + existed: bool, + }, + + Error { + message: String, + }, + + // === Frames de streaming de ExecStream === + // El daemon emite una secuencia de estos por cada `ExecStream`, en + // este orden lógico: `ExecStarted?` (cuando hay PID) → cualquier + // mezcla de `ExecStdout`/`ExecStderr`/`ExecTruncated`/`ExecSpilled` + // → terminal `ExecExited(_)` o `ExecFailed(_)`. Tras el terminal, la + // conexión vuelve a modo request/response. + + /// El proceso (o la primera etapa de un pipe) arrancó con este PID. + /// Útil para el cliente para mostrar/auditar; opcional, no todos los + /// modos lo emiten. + ExecStarted { pid: i32 }, + /// Una línea de stdout del proceso. + ExecStdout(String), + /// Una línea de stdout de una etapa **intermedia** de un pipe + /// `Direct` (tee). Sólo aparece si el cliente pidió + /// `capture_stages: true` en el `ExecStream`. `stage` es el índice + /// 0-based de la etapa que la emitió. Espeja + /// `shuma_exec::RunEvent::StageStdout`. + ExecStageStdout { stage: usize, line: String }, + /// Una línea de stderr del proceso. + ExecStderr(String), + /// La captura alcanzó el tope; lo siguiente se descarta. El proceso + /// sigue corriendo (su pipe se sigue drenando del lado del daemon). + ExecTruncated, + /// La captura excedió el tope y la cola se está volcando al fichero + /// indicado (path en el lado del daemon, no del cliente). + ExecSpilled(String), + /// Bytes crudos de salida de un PTY remoto (`ExecPty`): stdout y + /// stderr mezclados, con escapes ANSI. El cliente los alimenta tal + /// cual a su parser vt100. No es terminal. + ExecBytes(Vec), + /// Terminal. Código de salida del proceso (de la última etapa en un + /// pipe directo). + ExecExited(i32), + /// Terminal. El proceso no se pudo ni lanzar (binario inexistente, + /// permisos, etc.). + ExecFailed(String), +} + +impl Response { + /// `true` si este frame cierra un `ExecStream` (último que el + /// cliente leerá antes de volver al modo request/response). + pub fn is_exec_terminal(&self) -> bool { + matches!(self, Response::ExecExited(_) | Response::ExecFailed(_)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuotaReportInfo { + pub mem_limit: Option, + pub nproc_limit: Option, + pub breaches: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceStatsInfo { + pub commands_alive: u32, + pub commands_total: u32, + pub rss_bytes: Option, + #[serde(default)] + pub rss_peak_bytes: Option, + pub cpu_usec: Option, + #[serde(default)] + pub cpu_percent: Option, + #[serde(default = "default_cpu_cores")] + pub cpu_cores: u32, + pub source: String, + pub uptime_ms: u64, +} + +fn default_cpu_cores() -> u32 { + 1 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowThroughputInfo { + pub socket: PathBuf, + pub bytes_total: u64, + pub bytes_per_sec: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlowInfo { + pub pipeline: Ulid, + pub sockets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommandInfo { + pub id: Ulid, + pub label: String, + pub pid: i32, + pub alive: bool, + pub exit_status: Option, + pub log_bytes: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EdgeDiscernmentInfo { + pub from_label: String, + pub from_output: String, + pub to_label: String, + pub to_input: String, + /// `Some(ty)` si el discerner detectó algo. `None` si no hubo data + /// suficiente o no matcheó ningún discerner. + pub ty: Option, + pub mime: Option, + pub lens: Option, + pub confidence: f32, + /// Path del Unix socket donde otros módulos pueden suscribirse a los + /// bytes replicados de este edge (data plane). `None` si tap=false. + pub flow_socket: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceSummary { + pub id: WorkspaceId, + pub label: String, + pub commands: u32, + pub uptime_ms: u64, +} + +// ===================================================================== +// Errores +// ===================================================================== + +#[derive(Debug, Error)] +pub enum ProtocolError { + #[error("frame oversize: {0} bytes (max {MAX_FRAME})")] + FrameOversize(usize), + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("postcard: {0}")] + Postcard(#[from] postcard::Error), + #[error("connection closed")] + Closed, +} + +// ===================================================================== +// Framing helpers +// ===================================================================== + +// Genéricos sobre el transporte (`AsyncRead`/`AsyncWrite`) y no sólo +// `UnixStream`: así el sub-protocolo PTY puede leer y escribir frames en +// las **mitades** de un stream splitteado (`ReadHalf`/`WriteHalf`) para +// hablar full-duplex sin cortar a mitad de frame. `UnixStream` cumple el +// bound, así que todos los callers previos siguen compilando igual. +pub async fn write_frame(stream: &mut S, msg: &T) -> Result<(), ProtocolError> +where + T: Serialize, + S: AsyncWriteExt + Unpin, +{ + let bytes = postcard::to_allocvec(msg)?; + if bytes.len() > MAX_FRAME { + return Err(ProtocolError::FrameOversize(bytes.len())); + } + let len = (bytes.len() as u32).to_be_bytes(); + stream.write_all(&len).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + Ok(()) +} + +pub async fn read_frame(stream: &mut S) -> Result +where + T: for<'de> Deserialize<'de>, + S: AsyncReadExt + Unpin, +{ + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + ProtocolError::Closed + } else { + ProtocolError::Io(e) + } + })?; + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_FRAME { + return Err(ProtocolError::FrameOversize(len)); + } + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf).await?; + Ok(postcard::from_bytes(&buf)?) +} + +/// Path canónico del socket del daemon: `$XDG_RUNTIME_DIR/shuma.sock`, +/// fallback `/run/user/$UID/shuma.sock`, fallback `/tmp/shuma-$UID.sock`. +pub fn default_socket_path() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(xdg).join(DEFAULT_SOCK_NAME); + } + let uid = nix::unistd::getuid().as_raw(); + let p = PathBuf::from(format!("/run/user/{uid}")); + if p.exists() { + return p.join(DEFAULT_SOCK_NAME); + } + PathBuf::from(format!("/tmp/shuma-{uid}.sock")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ping_roundtrip() { + let bytes = postcard::to_allocvec(&Request::Ping).unwrap(); + let back: Request = postcard::from_bytes(&bytes).unwrap(); + assert!(matches!(back, Request::Ping)); + } + + #[test] + fn workspace_create_roundtrip() { + let req = Request::WorkspaceCreate { + spec: WorkspaceSpec { + label: "demo".into(), + soma: Default::default(), + permissions: Default::default(), + ttl: None, + flow_dirs: vec![], + on_exit: shuma_card::ExitPolicy::Reap, + quota_enforce: Default::default(), + }, + }; + let bytes = postcard::to_allocvec(&req).unwrap(); + let back: Request = postcard::from_bytes(&bytes).unwrap(); + match back { + Request::WorkspaceCreate { spec } => assert_eq!(spec.label, "demo"), + _ => panic!("wrong variant"), + } + } + + #[test] + fn default_socket_path_uses_runtime_dir() { + let p = default_socket_path(); + assert!(p.to_string_lossy().ends_with("shuma.sock")); + } + + #[test] + fn exec_stream_request_round_trips() { + let req = Request::ExecStream { + cwd: "/tmp".into(), + exec: ExecKind::Direct { + stages: vec![ + ExecStage { program: "echo".into(), args: vec!["hola".into()] }, + ], + }, + capture_limit_bytes: 1024, + stdin_data: None, + capture_stages: true, + }; + let bytes = postcard::to_allocvec(&req).unwrap(); + let back: Request = postcard::from_bytes(&bytes).unwrap(); + match back { + Request::ExecStream { cwd, exec, capture_limit_bytes, stdin_data, capture_stages } => { + assert_eq!(cwd, "/tmp"); + assert_eq!(capture_limit_bytes, 1024); + assert!(stdin_data.is_none()); + assert!(capture_stages); + if let ExecKind::Direct { stages } = exec { + assert_eq!(stages.len(), 1); + assert_eq!(stages[0].program, "echo"); + } else { + panic!("expected Direct"); + } + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn exec_terminal_detector() { + assert!(Response::ExecExited(0).is_exec_terminal()); + assert!(Response::ExecFailed("x".into()).is_exec_terminal()); + assert!(!Response::ExecStdout("línea".into()).is_exec_terminal()); + assert!(!Response::ExecStderr("warn".into()).is_exec_terminal()); + assert!(!Response::ExecTruncated.is_exec_terminal()); + assert!(!Response::ExecSpilled("/tmp/x".into()).is_exec_terminal()); + // El terminal sólo aplica al subprotocolo Exec — un Pong NO es terminal. + assert!(!Response::Pong.is_exec_terminal()); + } + + #[test] + fn exec_stream_event_frames_round_trip() { + let evs = vec![ + Response::ExecStarted { pid: 4242 }, + Response::ExecStdout("hola".into()), + Response::ExecStderr("warn".into()), + Response::ExecTruncated, + Response::ExecSpilled("/tmp/shuma-spill.log".into()), + Response::ExecExited(0), + ]; + for ev in evs { + let bytes = postcard::to_allocvec(&ev).unwrap(); + let back: Response = postcard::from_bytes(&bytes).unwrap(); + // El round-trip preserva la variante. + assert_eq!( + std::mem::discriminant(&ev), + std::mem::discriminant(&back), + ); + } + } +} diff --git a/02_ruway/shuma/sandbox/shuma-remote-exec/Cargo.toml b/02_ruway/shuma/sandbox/shuma-remote-exec/Cargo.toml new file mode 100644 index 0000000..2e6cb3c --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-remote-exec/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "shuma-remote-exec" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — cliente sync del subprotocolo ExecStream del daemon. RemoteRunHandle paralelo a shuma_exec::RunHandle, hablando por Unix socket." + +[dependencies] +shuma-exec = { path = "../shuma-exec" } +shuma-protocol = { path = "../shuma-protocol" } +shuma-link = { path = "../shuma-link" } +tokio = { workspace = true } +thiserror = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-remote-exec/LEEME.md b/02_ruway/shuma/sandbox/shuma-remote-exec/LEEME.md new file mode 100644 index 0000000..afa9216 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-remote-exec/LEEME.md @@ -0,0 +1,9 @@ +# shuma-remote-exec + +> Exec remoto vía gateway de [shuma](../../README.md). + +Ejecuta un comando en un host remoto con stdout/stderr streameado en vivo. Sin pty (es un exec, no shell). + +## Deps + +- [`shuma-protocol`](../shuma-protocol/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-remote-exec/README.md b/02_ruway/shuma/sandbox/shuma-remote-exec/README.md new file mode 100644 index 0000000..708d14f --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-remote-exec/README.md @@ -0,0 +1,9 @@ +# shuma-remote-exec + +> Gateway-mediated remote exec of [shuma](../../README.md). + +Runs a command on a remote host with stdout/stderr streamed live. No pty (it's an exec, not a shell). + +## Deps + +- [`shuma-protocol`](../shuma-protocol/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs b/02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs new file mode 100644 index 0000000..f80ca81 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-remote-exec/src/lib.rs @@ -0,0 +1,1205 @@ +//! `shuma-remote-exec` — cliente **sync** del subprotocolo +//! [`ExecStream`](shuma_protocol::Request::ExecStream) del daemon. +//! +//! El shell GPUI no es async; ejecuta comandos a través de +//! `shuma-exec` (un crate puramente sync con threads + mpsc) y drena +//! los eventos con `try_events()`. Este crate provee la misma forma — +//! [`RemoteRunHandle`] paralelo a [`shuma_exec::RunHandle`] — pero los +//! eventos vienen del **daemon** por Unix socket, no del proceso local. +//! +//! Eso permite que el shell sea cliente delgado contra `shuma-daemon`: +//! una vez que el transporte abstrae la conexión, sustituir el Unix +//! socket por una conexión autenticada (Bloque 7) o un túnel SSH es un +//! cambio de implementación, no de API. +//! +//! Arquitectura interna: +//! +//! ```text +//! shell (sync) ──┐ +//! ├─ try_events / kill / is_finished (idéntico a shuma-exec) +//! RemoteRunHandle ┤ +//! └─ background thread: tokio runtime ─ UnixStream ─ daemon +//! ``` +//! +//! Un único hilo dedicado abre su propio runtime de tokio y conecta al +//! socket; lee frames y los reemite como `RunEvent`s por un canal +//! mpsc estándar. `kill()` cierra el stream — el daemon detecta el +//! EOF y mata al proceso hijo (convención SSH/PTY). + +#![forbid(unsafe_code)] + +pub use shuma_exec::RunEvent; +use shuma_exec::{CommandSpec, Exec}; +use shuma_protocol::{ + read_frame, write_frame, ExecKind, ExecStage, Request, Response, +}; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::sync::Arc; +use tokio::sync::Notify; + +/// Asa de un comando que se ejecuta en el daemon. API a propósito +/// idéntica (en spirit) a [`shuma_exec::RunHandle`] para que el shell +/// pueda usar ambos detrás del mismo trait. +pub struct RemoteRunHandle { + rx: Receiver, + finished: bool, + /// Señalización al hilo de fondo para cancelar (cerrar el stream). + /// El daemon detecta el EOF y mata al proceso. + cancel: Arc, + /// Canal de salida cliente→daemon para sesiones **PTY** — lleva + /// `PtyInput`/`PtyResize`. `None` en runs no-PTY (`run`/`run_tcp`), + /// donde `write_input`/`resize` son no-op igual que en local. + pty_out: Option>, +} + +impl RemoteRunHandle { + /// Mata el proceso remoto cerrando el stream — el daemon lo detecta + /// y dispara SIGKILL en el proceso hijo. + pub fn kill(&self) { + self.cancel.notify_waiters(); + } + + /// Envía bytes de stdin al PTY remoto. `false` si no es una sesión + /// PTY o el hilo de fondo ya cerró. + pub fn write_input(&self, bytes: Vec) -> bool { + match &self.pty_out { + Some(tx) => tx.send(Request::PtyInput { bytes }).is_ok(), + None => false, + } + } + + /// Reescala el PTY remoto. `false` fuera de una sesión PTY. + pub fn resize(&self, rows: u16, cols: u16) -> bool { + match &self.pty_out { + Some(tx) => tx.send(Request::PtyResize { rows, cols }).is_ok(), + None => false, + } + } + + /// Drena eventos disponibles ahora, sin bloquear. + pub fn try_events(&mut self) -> Vec { + let mut out = Vec::new(); + loop { + match self.rx.try_recv() { + Ok(ev) => { + if matches!(ev, RunEvent::Exited(_) | RunEvent::Failed(_)) { + self.finished = true; + } + out.push(ev); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + self.finished = true; + break; + } + } + } + out + } + + /// Bloquea hasta el próximo evento. `None` cuando terminó. + pub fn next_event(&mut self) -> Option { + if self.finished { + return None; + } + match self.rx.recv() { + Ok(ev) => { + if matches!(ev, RunEvent::Exited(_) | RunEvent::Failed(_)) { + self.finished = true; + } + Some(ev) + } + Err(_) => { + self.finished = true; + None + } + } + } + + /// `true` si el evento terminal ya pasó. + pub fn is_finished(&self) -> bool { + self.finished + } +} + +/// Errores que el shell puede ver al pedir un run remoto. La política +/// es no-cae-el-shell: si el daemon no contesta, el caller traduce el +/// error a un `RunEvent::Failed` y continúa. +#[derive(Debug, thiserror::Error)] +pub enum RemoteExecError { + #[error("conexión Unix a {0}: {1}")] + Connect(PathBuf, std::io::Error), + #[error("conexión TCP a {0}: {1}")] + ConnectTcp(String, std::io::Error), + #[error("PTY remoto aún no soportado — usá el modo local para comandos TUI (vim, htop, etc.)")] + PtyNotSupported, +} + +/// Lanza `spec` contra el daemon en `socket` y devuelve un asa cuyos +/// eventos llegan en streaming. La función vuelve de inmediato — el +/// trabajo de I/O se hace en un hilo aparte con su propio runtime. +pub fn run(spec: &CommandSpec, socket: &std::path::Path) -> Result { + let (tx, rx) = std::sync::mpsc::channel::(); + let cancel = Arc::new(Notify::new()); + let cancel_thread = cancel.clone(); + let socket_owned = socket.to_path_buf(); + + // Traducción a tipos del protocolo (los del crate `shuma-protocol` + // son los Serialize; los de `shuma-exec` son los locales). + // PTY remoto no está soportado aún — `ExecStream` es unidireccional + // (cliente→server: una Request, server→cliente: N events). Para PTY + // remoto haría falta un canal stdin server-bound (frame Input{bytes}) + // y resize, en un protocolo distinto. Por ahora se rechaza upfront + // para que el shell pueda fallback a local con un mensaje útil. + let exec_proto = match &spec.exec { + Exec::Shell { line, program } => ExecKind::Shell { + line: line.clone(), + program: program.clone(), + }, + Exec::Direct { stages } => ExecKind::Direct { + stages: stages + .iter() + .map(|s| ExecStage { program: s.program.clone(), args: s.args.clone() }) + .collect(), + }, + Exec::Pty { .. } => { + return Err(RemoteExecError::PtyNotSupported); + } + }; + let req = Request::ExecStream { + cwd: spec.cwd.clone(), + exec: exec_proto, + capture_limit_bytes: spec.capture_limit, + stdin_data: spec.stdin_data.clone(), + capture_stages: spec.capture_stages, + }; + + // Conexión sincronicamente: si falla, devolvemos antes de spawnear + // el hilo. Así el caller decide qué hacer. + let std_stream = std::os::unix::net::UnixStream::connect(&socket_owned) + .map_err(|e| RemoteExecError::Connect(socket_owned.clone(), e))?; + std_stream + .set_nonblocking(true) + .map_err(|e| RemoteExecError::Connect(socket_owned.clone(), e))?; + + std::thread::spawn(move || { + // Cada hilo de un comando trae su propio runtime current-thread — + // bajo en overhead, y el shell puede tener varios en vuelo sin + // contender un runtime global. + let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("runtime: {e}"))); + return; + } + }; + rt.block_on(async move { + let mut stream = match tokio::net::UnixStream::from_std(std_stream) { + Ok(s) => s, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("from_std: {e}"))); + return; + } + }; + if let Err(e) = write_frame(&mut stream, &req).await { + let _ = tx.send(RunEvent::Failed(format!("write request: {e}"))); + return; + } + // Loop: leer frames del daemon, traducirlos a RunEvent y + // reemitirlos. Si el cliente cancela, cerramos el stream y + // el daemon mata al hijo. + loop { + tokio::select! { + biased; + _ = cancel_thread.notified() => { + // Cerrar — el daemon detectará EOF. + drop(stream); + return; + } + res = read_frame::(&mut stream) => { + let resp = match res { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("read frame: {e}"))); + return; + } + }; + let terminal = resp.is_exec_terminal(); + if let Some(ev) = response_to_event(resp) { + if tx.send(ev).is_err() { + // El consumidor desapareció: cerrar para + // que el daemon mate al hijo. + return; + } + } + if terminal { + return; + } + } + } + } + }); + }); + + Ok(RemoteRunHandle { rx, finished: false, cancel, pty_out: None }) +} + +fn response_to_event(r: Response) -> Option { + match r { + Response::ExecStarted { .. } => None, // metadato, no es un RunEvent + Response::ExecStdout(l) => Some(RunEvent::Stdout(l)), + Response::ExecStageStdout { stage, line } => { + Some(RunEvent::StageStdout { stage, line }) + } + Response::ExecBytes(b) => Some(RunEvent::Bytes(b)), + Response::ExecStderr(l) => Some(RunEvent::Stderr(l)), + Response::ExecTruncated => Some(RunEvent::Truncated), + Response::ExecSpilled(p) => Some(RunEvent::Spilled(p)), + Response::ExecExited(c) => Some(RunEvent::Exited(c)), + Response::ExecFailed(m) => Some(RunEvent::Failed(m)), + other => Some(RunEvent::Failed(format!( + "frame inesperado en stream: {other:?}" + ))), + } +} + +/// Convenience: usa la ruta canónica del socket que defina +/// [`shuma_protocol::default_socket_path`]. +pub fn run_default(spec: &CommandSpec) -> Result { + run(spec, &shuma_protocol::default_socket_path()) +} + +/// Extrae `(program, args, rows, cols)` de un spec PTY; `None` si el spec +/// no es `Exec::Pty`. +fn pty_fields(spec: &CommandSpec) -> Option<(String, Vec, u16, u16)> { + match &spec.exec { + Exec::Pty { program, args, rows, cols } => { + Some((program.clone(), args.clone(), *rows, *cols)) + } + _ => None, + } +} + +/// Abre un **PTY remoto** sobre el socket Unix del daemon. A diferencia de +/// [`run`], la conexión es full-duplex: el asa devuelta acepta +/// `write_input`/`resize` (teclas y resize → daemon) mientras drena +/// `RunEvent::Bytes` (la salida del terminal). El spec debe ser +/// `Exec::Pty`; si no, devuelve [`RemoteExecError::PtyNotSupported`]. +pub fn run_pty( + spec: &CommandSpec, + socket: &std::path::Path, +) -> Result { + let Some((program, args, rows, cols)) = pty_fields(spec) else { + return Err(RemoteExecError::PtyNotSupported); + }; + let (tx, rx) = std::sync::mpsc::channel::(); + let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::(); + let cancel = Arc::new(Notify::new()); + let cancel_thread = cancel.clone(); + let socket_owned = socket.to_path_buf(); + let req = Request::ExecPty { cwd: spec.cwd.clone(), program, args, rows, cols }; + + let std_stream = std::os::unix::net::UnixStream::connect(&socket_owned) + .map_err(|e| RemoteExecError::Connect(socket_owned.clone(), e))?; + std_stream + .set_nonblocking(true) + .map_err(|e| RemoteExecError::Connect(socket_owned.clone(), e))?; + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("runtime: {e}"))); + return; + } + }; + rt.block_on(async move { + let stream = match tokio::net::UnixStream::from_std(std_stream) { + Ok(s) => s, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("from_std: {e}"))); + return; + } + }; + let (mut rd, mut wr) = tokio::io::split(stream); + // Mandamos la apertura del PTY por la mitad de escritura. + if let Err(e) = write_frame(&mut wr, &req).await { + let _ = tx.send(RunEvent::Failed(format!("write request: {e}"))); + return; + } + // Tarea escritora: drena teclas/resizes del shell → daemon. + let writer = tokio::spawn(async move { + while let Some(msg) = out_rx.recv().await { + if write_frame(&mut wr, &msg).await.is_err() { + break; + } + } + }); + // Loop lector: salida del PTY → RunEvent, hasta terminal/cancel. + loop { + tokio::select! { + biased; + _ = cancel_thread.notified() => break, + res = read_frame::(&mut rd) => { + let resp = match res { + Ok(r) => r, + Err(_) => break, // EOF/error: el daemon cerró + }; + let terminal = resp.is_exec_terminal(); + if let Some(ev) = response_to_event(resp) { + if tx.send(ev).is_err() { + break; + } + } + if terminal { + break; + } + } + } + } + writer.abort(); + // Al dropear `rd`/`wr` se cierra el stream → el daemon mata el PTY. + }); + }); + + Ok(RemoteRunHandle { rx, finished: false, cancel, pty_out: Some(out_tx) }) +} + +/// Variante autenticada y cifrada vía Noise XK sobre TCP — espejo de +/// [`run`] para hablar con un daemon **remoto**. El cliente conoce de +/// antemano la pubkey del servidor (`server_pub`, igual que +/// `known_hosts` en SSH); el server valida nuestra pubkey contra su +/// propio allowlist. +/// +/// El `RemoteRunHandle` que devuelve tiene la misma forma que el del +/// Unix path — el shell consume `try_events / kill / is_finished` +/// igual en los dos casos. +pub fn run_tcp( + spec: &CommandSpec, + addr: &str, + our_keypair: shuma_link::Keypair, + server_pub: shuma_link::PublicKey, +) -> Result { + let (tx, rx) = std::sync::mpsc::channel::(); + let cancel = Arc::new(Notify::new()); + let cancel_thread = cancel.clone(); + let addr_owned = addr.to_string(); + + // Mismo proto Request que el path Unix. + let exec_proto = match &spec.exec { + Exec::Shell { line, program } => ExecKind::Shell { + line: line.clone(), + program: program.clone(), + }, + Exec::Direct { stages } => ExecKind::Direct { + stages: stages + .iter() + .map(|s| ExecStage { program: s.program.clone(), args: s.args.clone() }) + .collect(), + }, + Exec::Pty { .. } => { + return Err(RemoteExecError::PtyNotSupported); + } + }; + let req = Request::ExecStream { + cwd: spec.cwd.clone(), + exec: exec_proto, + capture_limit_bytes: spec.capture_limit, + stdin_data: spec.stdin_data.clone(), + capture_stages: spec.capture_stages, + }; + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("runtime: {e}"))); + return; + } + }; + rt.block_on(async move { + // 1) Conexión TCP. El error de conexión llega como Failed + // (en vez de Err(RemoteExecError::ConnectTcp)) porque el + // caller ya recibió el RemoteRunHandle: la forma de + // notificarle ahora es vía el canal de eventos. + let tcp = match tokio::net::TcpStream::connect(&addr_owned).await { + Ok(s) => s, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("connect {addr_owned}: {e}"))); + return; + } + }; + // 2) Handshake Noise XK. Si la pubkey del server no + // coincide con `server_pub`, falla aquí (protección MITM). + let mut ch = match shuma_link::client_handshake(tcp, &our_keypair, server_pub).await { + Ok(c) => c, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("handshake: {e}"))); + return; + } + }; + // 3) Misma forma que el Unix path: enviar la request y + // drenar Response frames hasta el terminal o cancel. + if let Err(e) = ch.send_postcard(&req).await { + let _ = tx.send(RunEvent::Failed(format!("send request: {e}"))); + return; + } + loop { + tokio::select! { + biased; + _ = cancel_thread.notified() => { + drop(ch); + return; + } + res = ch.recv_postcard::() => { + let resp = match res { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("read frame: {e}"))); + return; + } + }; + let terminal = resp.is_exec_terminal(); + if let Some(ev) = response_to_event(resp) { + if tx.send(ev).is_err() { + return; + } + } + if terminal { + return; + } + } + } + } + }); + }); + + Ok(RemoteRunHandle { rx, finished: false, cancel, pty_out: None }) +} + +/// Espejo cifrado (Noise XK sobre TCP) de [`run_pty`] — PTY contra un +/// daemon **remoto**. Misma forma full-duplex; el asa devuelta acepta +/// `write_input`/`resize`. +pub fn run_pty_tcp( + spec: &CommandSpec, + addr: &str, + our_keypair: shuma_link::Keypair, + server_pub: shuma_link::PublicKey, +) -> Result { + let Some((program, args, rows, cols)) = pty_fields(spec) else { + return Err(RemoteExecError::PtyNotSupported); + }; + let (tx, rx) = std::sync::mpsc::channel::(); + let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::(); + let cancel = Arc::new(Notify::new()); + let cancel_thread = cancel.clone(); + let addr_owned = addr.to_string(); + let req = Request::ExecPty { cwd: spec.cwd.clone(), program, args, rows, cols }; + + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(r) => r, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("runtime: {e}"))); + return; + } + }; + rt.block_on(async move { + let tcp = match tokio::net::TcpStream::connect(&addr_owned).await { + Ok(s) => s, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("connect {addr_owned}: {e}"))); + return; + } + }; + let ch = match shuma_link::client_handshake(tcp, &our_keypair, server_pub).await { + Ok(c) => c, + Err(e) => { + let _ = tx.send(RunEvent::Failed(format!("handshake: {e}"))); + return; + } + }; + let (mut rd, mut wr) = ch.split(); + if let Err(e) = wr.send_postcard(&req).await { + let _ = tx.send(RunEvent::Failed(format!("send request: {e}"))); + return; + } + let writer = tokio::spawn(async move { + while let Some(msg) = out_rx.recv().await { + if wr.send_postcard(&msg).await.is_err() { + break; + } + } + }); + loop { + tokio::select! { + biased; + _ = cancel_thread.notified() => break, + res = rd.recv_postcard::() => { + let resp = match res { + Ok(r) => r, + Err(_) => break, + }; + let terminal = resp.is_exec_terminal(); + if let Some(ev) = response_to_event(resp) { + if tx.send(ev).is_err() { + break; + } + } + if terminal { + break; + } + } + } + } + writer.abort(); + }); + }); + + Ok(RemoteRunHandle { rx, finished: false, cancel, pty_out: Some(out_tx) }) +} + +// Re-exports útiles para el shell. +pub use shuma_exec::{CommandSpec as Spec, Exec as ExecLocal, StageSpec as Stage}; + +#[cfg(test)] +mod tests { + use super::*; + use shuma_exec::StageSpec; + use shuma_protocol::ExecKind as ProtoExecKind; + use std::time::Duration; + + /// Mini servidor que sólo entiende ExecStream — simula al daemon + /// pero sin sus dependencias pesadas (cgroups, etc.). Usa el mismo + /// puente sync→async que el daemon real. + async fn serve_exec_stream(mut stream: tokio::net::UnixStream) { + let req: Request = read_frame(&mut stream).await.expect("read request"); + let Request::ExecStream { cwd, exec, capture_limit_bytes, stdin_data, capture_stages } = req + else { + panic!("esperaba ExecStream"); + }; + let exec_local = match exec { + ProtoExecKind::Shell { line, program } => Exec::Shell { line, program }, + ProtoExecKind::Direct { stages } => Exec::Direct { + stages: stages + .into_iter() + .map(|s| StageSpec { program: s.program, args: s.args }) + .collect(), + }, + }; + let spec = CommandSpec { + exec: exec_local, + cwd, + capture_limit: capture_limit_bytes, + spill_path: None, + stdin_data, + capture_stages, + }; + let mut h = shuma_exec::run(&spec); + let killer = h.killer(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + std::thread::spawn(move || { + while let Some(ev) = h.next_event() { + if tx.send(ev).is_err() { + return; + } + } + }); + let mut byte = [0u8; 1]; + loop { + tokio::select! { + biased; + ev = rx.recv() => { + let Some(ev) = ev else { break }; + let terminal = matches!(ev, RunEvent::Exited(_) | RunEvent::Failed(_)); + let resp = match ev { + RunEvent::Stdout(l) => Response::ExecStdout(l), + RunEvent::StageStdout { stage, line } => { + Response::ExecStageStdout { stage, line } + } + RunEvent::Stderr(l) => Response::ExecStderr(l), + RunEvent::Truncated => Response::ExecTruncated, + RunEvent::Spilled(p) => Response::ExecSpilled(p), + RunEvent::Exited(c) => Response::ExecExited(c), + RunEvent::Failed(m) => Response::ExecFailed(m), + // Test server no puede levantar PTY (los tests + // usan Exec::Direct), pero el match debe ser + // exhaustivo. + RunEvent::Bytes(_) => continue, + }; + if write_frame(&mut stream, &resp).await.is_err() { + killer.kill(); + return; + } + if terminal { + break; + } + } + res = tokio::io::AsyncReadExt::read_exact(&mut stream, &mut byte) => { + let _ = res; + killer.kill(); + while let Some(ev) = rx.recv().await { + if matches!(ev, RunEvent::Exited(_) | RunEvent::Failed(_)) { + break; + } + } + break; + } + } + } + } + + /// Arranca un mini servidor en `path` que sirve UNA conexión y se + /// apaga. Devuelve cuando termina la atención. + async fn one_shot_server(path: std::path::PathBuf) { + let listener = tokio::net::UnixListener::bind(&path).expect("bind"); + let (stream, _) = listener.accept().await.expect("accept"); + serve_exec_stream(stream).await; + let _ = std::fs::remove_file(&path); + } + + fn temp_socket() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "shuma-remote-test-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = std::fs::remove_file(&p); + p + } + + #[test] + fn echo_round_trips_through_remote_client() { + let sock = temp_socket(); + let sock_for_server = sock.clone(); + let server = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(one_shot_server(sock_for_server)); + }); + // Esperamos a que el server bindee. + while !sock.exists() { + std::thread::sleep(Duration::from_millis(10)); + } + let spec = CommandSpec::direct( + vec![StageSpec { + program: "echo".into(), + args: vec!["hola".into(), "remoto".into()], + }], + ".", + ); + let mut h = run(&spec, &sock).expect("run remote"); + let mut got_line = String::new(); + while let Some(ev) = h.next_event() { + if let RunEvent::Stdout(l) = ev { + got_line = l; + } + } + server.join().unwrap(); + assert_eq!(got_line, "hola remoto"); + assert!(h.is_finished()); + } + + #[test] + fn stage_capture_round_trips_through_remote_client() { + // Un pipe de dos etapas con captura activa: el tee de la etapa + // intermedia debe sobrevivir el transporte y volver como + // `RunEvent::StageStdout`, no perderse ni colapsar a Stdout. + let sock = temp_socket(); + let sock_for_server = sock.clone(); + let server = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(one_shot_server(sock_for_server)); + }); + while !sock.exists() { + std::thread::sleep(Duration::from_millis(10)); + } + let spec = CommandSpec::direct( + vec![ + StageSpec { program: "printf".into(), args: vec!["a\\nb\\n".into()] }, + StageSpec { program: "cat".into(), args: vec![] }, + ], + ".", + ) + .with_stage_capture(); + let mut h = run(&spec, &sock).expect("run remote"); + let mut saw_stage = false; + while let Some(ev) = h.next_event() { + if let RunEvent::StageStdout { stage, line } = ev { + if stage == 0 && line == "a" { + saw_stage = true; + } + } + } + server.join().unwrap(); + assert!(saw_stage, "el tee de la etapa intermedia no llegó por el cliente remoto"); + assert!(h.is_finished()); + } + + #[test] + fn kill_propagates_to_remote_process() { + let sock = temp_socket(); + let sock_for_server = sock.clone(); + let server = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(one_shot_server(sock_for_server)); + }); + while !sock.exists() { + std::thread::sleep(Duration::from_millis(10)); + } + let spec = CommandSpec::direct( + vec![StageSpec { program: "sleep".into(), args: vec!["30".into()] }], + ".", + ); + let mut h = run(&spec, &sock).expect("run remote"); + // Le damos tiempo a arrancar; luego pedimos kill. + std::thread::sleep(Duration::from_millis(150)); + h.kill(); + // El stream se cierra, el server mata al hijo, el next_event sale + // (puede salir como None si no llega ningún terminal — el server + // ya cerró antes de poder enviarlo). + let started = std::time::Instant::now(); + while h.next_event().is_some() { + if started.elapsed() > Duration::from_secs(5) { + panic!("kill no terminó el stream en 5s"); + } + } + assert!(h.is_finished()); + server.join().unwrap(); + } + + #[test] + fn connect_error_surfaces_to_caller() { + let no_such = std::path::PathBuf::from("/tmp/shuma-no-such-socket.sock"); + let _ = std::fs::remove_file(&no_such); + let spec = CommandSpec::direct( + vec![StageSpec { program: "true".into(), args: vec![] }], + ".", + ); + match run(&spec, &no_such) { + Err(RemoteExecError::Connect(_, _)) => {} + Err(e) => panic!("variante de error inesperada: {e:?}"), + Ok(_) => panic!("debería fallar al conectar a un socket inexistente"), + } + } + + // ----- Tests del path TCP autenticado (Noise XK) ----- + + /// Espejo de `serve_exec_stream` para FramedChannel — sirve un único + /// ExecStream sobre el canal cifrado. Replica la forma del + /// `handle_exec_stream_enc` del daemon pero in-process para el test. + async fn serve_one_exec_enc( + mut ch: shuma_link::FramedChannel, + ) { + let req: Request = ch.recv_postcard().await.expect("recv request"); + let Request::ExecStream { cwd, exec, capture_limit_bytes, stdin_data, capture_stages } = req + else { + panic!("esperaba ExecStream"); + }; + let exec_local = match exec { + ProtoExecKind::Shell { line, program } => Exec::Shell { line, program }, + ProtoExecKind::Direct { stages } => Exec::Direct { + stages: stages + .into_iter() + .map(|s| StageSpec { program: s.program, args: s.args }) + .collect(), + }, + }; + let spec = CommandSpec { + exec: exec_local, + cwd, + capture_limit: capture_limit_bytes, + spill_path: None, + stdin_data, + capture_stages, + }; + let mut h = shuma_exec::run(&spec); + let killer = h.killer(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + std::thread::spawn(move || { + while let Some(ev) = h.next_event() { + if tx.send(ev).is_err() { + return; + } + } + }); + while let Some(ev) = rx.recv().await { + let terminal = matches!(ev, RunEvent::Exited(_) | RunEvent::Failed(_)); + let resp = match ev { + RunEvent::Stdout(l) => Response::ExecStdout(l), + RunEvent::StageStdout { stage, line } => { + Response::ExecStageStdout { stage, line } + } + RunEvent::Stderr(l) => Response::ExecStderr(l), + RunEvent::Truncated => Response::ExecTruncated, + RunEvent::Spilled(p) => Response::ExecSpilled(p), + RunEvent::Exited(c) => Response::ExecExited(c), + RunEvent::Failed(m) => Response::ExecFailed(m), + RunEvent::Bytes(_) => continue, // Test server no usa PTY + }; + if ch.send_postcard(&resp).await.is_err() { + killer.kill(); + return; + } + if terminal { + break; + } + } + } + + #[test] + fn echo_round_trips_through_encrypted_tcp_client() { + // Bind a localhost con puerto efímero para evitar choque. + let server_kp = shuma_link::Keypair::generate().unwrap(); + let client_kp = shuma_link::Keypair::generate().unwrap(); + let server_pub = server_kp.public(); + + // Server-thread con su propio runtime para no contender el del + // cliente (que vive dentro del hilo de `run_tcp`). + let server_kp2 = server_kp.clone(); + let (addr_tx, addr_rx) = std::sync::mpsc::channel::(); + let server_thread = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + addr_tx.send(addr).unwrap(); + let (tcp, _) = listener.accept().await.unwrap(); + let (ch, _peer) = shuma_link::server_handshake(tcp, &server_kp2).await.unwrap(); + serve_one_exec_enc(ch).await; + }); + }); + let addr = addr_rx.recv().unwrap(); + let spec = CommandSpec::direct( + vec![StageSpec { program: "echo".into(), args: vec!["hola".into(), "cifrado".into()] }], + ".", + ); + let mut h = run_tcp(&spec, &addr, client_kp, server_pub).unwrap(); + let mut got = String::new(); + while let Some(ev) = h.next_event() { + if let RunEvent::Stdout(l) = ev { + got = l; + } + } + server_thread.join().unwrap(); + assert_eq!(got, "hola cifrado"); + assert!(h.is_finished()); + } + + #[test] + fn wrong_server_pubkey_surfaces_as_failed_event() { + // El cliente espera la pubkey de un server "legítimo", pero el + // que responde es otro. El handshake debe fallar y eso llega al + // shell como un RunEvent::Failed, NO como un panic. + let real_server = shuma_link::Keypair::generate().unwrap(); + let attacker = shuma_link::Keypair::generate().unwrap(); + let client = shuma_link::Keypair::generate().unwrap(); + + let (addr_tx, addr_rx) = std::sync::mpsc::channel::(); + let server_thread = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + addr_tx.send(listener.local_addr().unwrap().to_string()).unwrap(); + let (tcp, _) = listener.accept().await.unwrap(); + // El "atacante" se identifica con su propia keypair. + let _ = shuma_link::server_handshake(tcp, &attacker).await; + }); + }); + let addr = addr_rx.recv().unwrap(); + let spec = CommandSpec::direct( + vec![StageSpec { program: "true".into(), args: vec![] }], + ".", + ); + let mut h = run_tcp(&spec, &addr, client, real_server.public()).unwrap(); + let mut saw_failed = false; + while let Some(ev) = h.next_event() { + if matches!(ev, RunEvent::Failed(_)) { + saw_failed = true; + } + } + server_thread.join().ok(); + assert!(saw_failed, "se esperaba RunEvent::Failed por pubkey errónea"); + } + + // ----- Tests del PTY remoto (full-duplex) ----- + + /// Spec PTY de conveniencia para los tests. + fn pty_spec(program: &str, args: &[&str]) -> CommandSpec { + CommandSpec { + exec: Exec::Pty { + program: program.into(), + args: args.iter().map(|s| s.to_string()).collect(), + rows: 24, + cols: 80, + }, + cwd: ".".into(), + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + } + } + + /// Mini servidor PTY in-process — replica `handle_pty_stream` del daemon + /// (texto plano) sin sus dependencias pesadas. + async fn serve_one_pty(mut stream: tokio::net::UnixStream) { + let req: Request = read_frame(&mut stream).await.expect("read req"); + let Request::ExecPty { cwd, program, args, rows, cols } = req else { + panic!("esperaba ExecPty"); + }; + let spec = CommandSpec { + exec: Exec::Pty { program, args, cols, rows }, + cwd, + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + }; + let mut h = shuma_exec::run(&spec); + let killer = h.killer(); + let pty = h.pty_control(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + std::thread::spawn(move || { + while let Some(ev) = h.next_event() { + if tx.send(ev).is_err() { + return; + } + } + }); + let (mut rd, mut wr) = tokio::io::split(stream); + let reader = tokio::spawn(async move { + loop { + match read_frame::(&mut rd).await { + Ok(Request::PtyInput { bytes }) => { + pty.write_input(bytes); + } + Ok(Request::PtyResize { rows, cols }) => { + pty.resize(rows, cols); + } + Ok(_) => {} + Err(_) => { + killer.kill(); + break; + } + } + } + }); + while let Some(ev) = rx.recv().await { + let terminal = matches!(ev, RunEvent::Exited(_) | RunEvent::Failed(_)); + let resp = match ev { + RunEvent::Bytes(b) => Response::ExecBytes(b), + RunEvent::Exited(c) => Response::ExecExited(c), + RunEvent::Failed(m) => Response::ExecFailed(m), + _ => continue, + }; + if write_frame(&mut wr, &resp).await.is_err() { + break; + } + if terminal { + break; + } + } + reader.abort(); + } + + async fn one_shot_pty_server(path: std::path::PathBuf) { + let listener = tokio::net::UnixListener::bind(&path).expect("bind"); + let (stream, _) = listener.accept().await.expect("accept"); + serve_one_pty(stream).await; + let _ = std::fs::remove_file(&path); + } + + #[test] + fn pty_streams_output_and_exits() { + // Un PTY que imprime y termina: los bytes deben llegar como + // `RunEvent::Bytes` y cerrar con `Exited`. + let sock = temp_socket(); + let sock_for_server = sock.clone(); + let server = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(one_shot_pty_server(sock_for_server)); + }); + while !sock.exists() { + std::thread::sleep(Duration::from_millis(10)); + } + let mut h = run_pty(&pty_spec("printf", &["pty-ok\\n"]), &sock).expect("run_pty"); + let mut got = Vec::new(); + let mut exited = false; + while let Some(ev) = h.next_event() { + match ev { + RunEvent::Bytes(b) => got.extend_from_slice(&b), + RunEvent::Exited(_) => exited = true, + _ => {} + } + } + server.join().unwrap(); + let text = String::from_utf8_lossy(&got); + assert!(text.contains("pty-ok"), "no llegó la salida del PTY: {text:?}"); + assert!(exited, "no llegó el evento Exited"); + assert!(h.is_finished()); + } + + #[test] + fn pty_forwards_input_to_remote() { + // `cat` bajo PTY: la línea tipeada se devuelve (eco de la línea + // de disciplina del terminal). Comprueba el camino cliente→daemon + // (`write_input` → `PtyInput`). + let sock = temp_socket(); + let sock_for_server = sock.clone(); + let server = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(one_shot_pty_server(sock_for_server)); + }); + while !sock.exists() { + std::thread::sleep(Duration::from_millis(10)); + } + let mut h = run_pty(&pty_spec("cat", &[]), &sock).expect("run_pty"); + std::thread::sleep(Duration::from_millis(150)); // que arranque cat + assert!(h.write_input(b"ping\n".to_vec()), "write_input debería encolar"); + let mut got = String::new(); + let start = std::time::Instant::now(); + loop { + for ev in h.try_events() { + if let RunEvent::Bytes(b) = ev { + got.push_str(&String::from_utf8_lossy(&b)); + } + } + if got.contains("ping") || start.elapsed() > Duration::from_secs(5) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + h.kill(); + server.join().unwrap(); + assert!(got.contains("ping"), "el PTY remoto no devolvió el eco del input: {got:?}"); + } + + /// Mini servidor PTY cifrado — replica `handle_pty_stream_enc` con + /// `FramedChannel::split`. + async fn serve_one_pty_enc(ch: shuma_link::FramedChannel) { + let (mut rd, mut wr) = ch.split(); + let req: Request = rd.recv_postcard().await.expect("recv req"); + let Request::ExecPty { cwd, program, args, rows, cols } = req else { + panic!("esperaba ExecPty"); + }; + let spec = CommandSpec { + exec: Exec::Pty { program, args, cols, rows }, + cwd, + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + }; + let mut h = shuma_exec::run(&spec); + let killer = h.killer(); + let pty = h.pty_control(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + std::thread::spawn(move || { + while let Some(ev) = h.next_event() { + if tx.send(ev).is_err() { + return; + } + } + }); + let reader = tokio::spawn(async move { + loop { + match rd.recv_postcard::().await { + Ok(Request::PtyInput { bytes }) => { + pty.write_input(bytes); + } + Ok(Request::PtyResize { rows, cols }) => { + pty.resize(rows, cols); + } + Ok(_) => {} + Err(_) => { + killer.kill(); + break; + } + } + } + }); + while let Some(ev) = rx.recv().await { + let terminal = matches!(ev, RunEvent::Exited(_) | RunEvent::Failed(_)); + let resp = match ev { + RunEvent::Bytes(b) => Response::ExecBytes(b), + RunEvent::Exited(c) => Response::ExecExited(c), + RunEvent::Failed(m) => Response::ExecFailed(m), + _ => continue, + }; + if wr.send_postcard(&resp).await.is_err() { + break; + } + if terminal { + break; + } + } + reader.abort(); + } + + #[test] + fn pty_round_trips_over_encrypted_tcp() { + // Mismo flujo PTY pero sobre Noise XK: valida `FramedChannel::split` + // y `run_pty_tcp` end-to-end. + let server_kp = shuma_link::Keypair::generate().unwrap(); + let client_kp = shuma_link::Keypair::generate().unwrap(); + let server_pub = server_kp.public(); + let server_kp2 = server_kp.clone(); + let (addr_tx, addr_rx) = std::sync::mpsc::channel::(); + let server_thread = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async move { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + addr_tx.send(listener.local_addr().unwrap().to_string()).unwrap(); + let (tcp, _) = listener.accept().await.unwrap(); + let (ch, _peer) = shuma_link::server_handshake(tcp, &server_kp2).await.unwrap(); + serve_one_pty_enc(ch).await; + }); + }); + let addr = addr_rx.recv().unwrap(); + let mut h = run_pty_tcp(&pty_spec("printf", &["pty-cifrado\\n"]), &addr, client_kp, server_pub) + .expect("run_pty_tcp"); + let mut got = Vec::new(); + let mut exited = false; + while let Some(ev) = h.next_event() { + match ev { + RunEvent::Bytes(b) => got.extend_from_slice(&b), + RunEvent::Exited(_) => exited = true, + _ => {} + } + } + server_thread.join().unwrap(); + let text = String::from_utf8_lossy(&got); + assert!(text.contains("pty-cifrado"), "no llegó la salida del PTY cifrado: {text:?}"); + assert!(exited); + assert!(h.is_finished()); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-session/Cargo.toml b/02_ruway/shuma/sandbox/shuma-session/Cargo.toml new file mode 100644 index 0000000..8cf6328 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-session/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shuma-session" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — el modelo de la sesión de trabajo: directorio actual (identificador de aislamiento), historial de comandos ejecutados y grupos reutilizables." + +[dependencies] +serde = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-session/LEEME.md b/02_ruway/shuma/sandbox/shuma-session/LEEME.md new file mode 100644 index 0000000..3c73896 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-session/LEEME.md @@ -0,0 +1,9 @@ +# shuma-session + +> Sesión persistente de [shuma](../../README.md). + +Estado vivo de una conversación shell: cwd, env, history, jobs en background. Persiste en `$XDG_DATA_HOME/shuma/sessions/`. + +## Deps + +- [`shuma-core`](../shuma-core/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-session/README.md b/02_ruway/shuma/sandbox/shuma-session/README.md new file mode 100644 index 0000000..d55e5a8 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-session/README.md @@ -0,0 +1,9 @@ +# shuma-session + +> Persistent session of [shuma](../../README.md). + +Live state of a shell conversation: cwd, env, history, background jobs. Persisted at `$XDG_DATA_HOME/shuma/sessions/`. + +## Deps + +- [`shuma-core`](../shuma-core/README.md) diff --git a/02_ruway/shuma/sandbox/shuma-session/src/lib.rs b/02_ruway/shuma/sandbox/shuma-session/src/lib.rs new file mode 100644 index 0000000..78e8637 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-session/src/lib.rs @@ -0,0 +1,420 @@ +//! `shuma-session` — la sesión de trabajo del shell. +//! +//! El shell no es una sucesión suelta de comandos: trabaja *dentro de +//! una sesión*. Una [`WorkSession`] contiene: +//! +//! - el **directorio actual** — que es además el identificador de +//! aislamiento (cada directorio es un contexto separado); +//! - el **historial** de comandos ejecutados, cada uno con su salida y +//! su estado ([`CommandRun`]); +//! - los **grupos** de comandos guardados y reutilizables +//! ([`CommandGroup`]). +//! +//! Modelo puro y agnóstico: la ejecución real la hace `shuma-exec`, el +//! tiempo lo inyecta el caller. Determinista y testeable. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +/// Identificador de un comando dentro de su sesión. +pub type RunId = u64; + +/// Política de captura de salida — configurable por sesión de trabajo. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapturePolicy { + /// Tope de captura en bytes; `0` = sin límite. + pub limit_bytes: usize, + /// Si la salida que excede el tope se vuelca a un archivo (en vez de + /// descartarse). + pub spill: bool, +} + +impl Default for CapturePolicy { + /// Por defecto: 8 MiB, sin volcado a disco. + fn default() -> Self { + Self { limit_bytes: 8 * 1024 * 1024, spill: false } + } +} + +/// Estado de un comando ejecutado. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RunStatus { + /// Ejecutándose; su salida sigue llegando. + Running, + /// Terminó con código 0. + Ok, + /// Terminó con código distinto de 0, o no pudo lanzarse. + Failed, +} + +/// De qué flujo viene una línea de salida. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Stream { + /// Salida estándar. + Stdout, + /// Salida de error. + Stderr, +} + +/// Una línea de salida con el flujo del que proviene. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OutputLine { + pub stream: Stream, + pub text: String, +} + +/// Un comando ejecutado: la línea, el directorio, el estado y la salida. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandRun { + pub id: RunId, + /// La línea de comandos tal como se escribió. + pub line: String, + /// El directorio en que se ejecutó. + pub cwd: String, + pub status: RunStatus, + /// Código de salida, una vez terminado. + pub exit_code: Option, + /// Salida — cada línea sabe si es de stdout o de stderr. + pub output: Vec, + /// `true` si la salida superó el tope de captura y se descartó parte. + pub truncated: bool, + /// Segundo Unix en que arrancó. + pub started_at: u64, + /// Segundo Unix en que terminó. + pub finished_at: Option, +} + +impl CommandRun { + /// `true` si el comando sigue corriendo. + pub fn is_running(&self) -> bool { + self.status == RunStatus::Running + } + + /// Cantidad total de líneas de salida. + pub fn line_count(&self) -> usize { + self.output.len() + } + + /// Líneas de un flujo concreto. + pub fn lines_of(&self, stream: Stream) -> impl Iterator { + self.output + .iter() + .filter(move |l| l.stream == stream) + .map(|l| l.text.as_str()) + } + + /// Cuántas líneas tiene un flujo. + pub fn count_of(&self, stream: Stream) -> usize { + self.output.iter().filter(|l| l.stream == stream).count() + } + + /// `true` si el comando emitió algo por stderr. + pub fn has_stderr(&self) -> bool { + self.output.iter().any(|l| l.stream == Stream::Stderr) + } +} + +/// Un grupo de comandos guardado para reutilizar — una receta. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandGroup { + pub name: String, + /// Las líneas de comando, en orden de ejecución. + pub lines: Vec, +} + +/// La sesión de trabajo: directorio actual + historial + grupos. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkSession { + pub name: String, + cwd: String, + history: Vec, + groups: Vec, + /// Política de captura — propia de esta sesión. + capture: CapturePolicy, + next_id: RunId, +} + +/// FNV-1a de 64 bits — base del identificador de aislamiento. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x100_0000_01b3); + } + h +} + +impl WorkSession { + /// Abre una sesión con un nombre y un directorio inicial. + pub fn new(name: impl Into, cwd: impl Into) -> Self { + Self { + name: name.into(), + cwd: cwd.into(), + history: Vec::new(), + groups: Vec::new(), + capture: CapturePolicy::default(), + next_id: 1, + } + } + + /// Política de captura vigente de la sesión. + pub fn capture(&self) -> CapturePolicy { + self.capture + } + + /// Reemplaza la política de captura. + pub fn set_capture(&mut self, policy: CapturePolicy) { + self.capture = policy; + } + + /// Ajusta sólo el tope de captura en bytes. + pub fn set_capture_limit(&mut self, bytes: usize) { + self.capture.limit_bytes = bytes; + } + + /// Activa o desactiva el volcado a disco de la salida excedente. + pub fn set_spill(&mut self, spill: bool) { + self.capture.spill = spill; + } + + /// Directorio actual de la sesión. + pub fn cwd(&self) -> &str { + &self.cwd + } + + /// Cambia el directorio actual — y con él, el contexto de aislamiento. + pub fn set_cwd(&mut self, cwd: impl Into) { + self.cwd = cwd.into(); + } + + /// Identificador de aislamiento del directorio actual: un hash corto + /// y estable del `cwd`. Cada directorio es un contexto distinto, así + /// que el id cambia al hacer `cd`. + pub fn isolation_id(&self) -> String { + format!("{:012x}", fnv1a(self.cwd.as_bytes()) & 0xffff_ffff_ffff) + } + + // --- Historial de comandos --- + + /// Registra el inicio de un comando (estado `Running`) en el `cwd` + /// actual. Devuelve su id. + pub fn begin_run(&mut self, line: impl Into, now: u64) -> RunId { + let id = self.next_id; + self.next_id += 1; + self.history.push(CommandRun { + id, + line: line.into(), + cwd: self.cwd.clone(), + status: RunStatus::Running, + exit_code: None, + output: Vec::new(), + truncated: false, + started_at: now, + finished_at: None, + }); + id + } + + /// Marca que la salida de un comando se truncó al tope de captura. + pub fn mark_truncated(&mut self, id: RunId) { + if let Some(r) = self.run_mut(id) { + r.truncated = true; + } + } + + pub fn run(&self, id: RunId) -> Option<&CommandRun> { + self.history.iter().find(|r| r.id == id) + } + + fn run_mut(&mut self, id: RunId) -> Option<&mut CommandRun> { + self.history.iter_mut().find(|r| r.id == id) + } + + /// Añade una línea de salida a un comando en curso, marcando su flujo. + pub fn append_output(&mut self, id: RunId, stream: Stream, text: impl Into) { + if let Some(r) = self.run_mut(id) { + r.output.push(OutputLine { stream, text: text.into() }); + } + } + + /// Marca un comando como terminado con su código de salida. + pub fn finish_run(&mut self, id: RunId, exit_code: i32, now: u64) { + if let Some(r) = self.run_mut(id) { + r.exit_code = Some(exit_code); + r.status = if exit_code == 0 { RunStatus::Ok } else { RunStatus::Failed }; + r.finished_at = Some(now); + } + } + + /// Historial completo, del más antiguo al más reciente. + pub fn history(&self) -> &[CommandRun] { + &self.history + } + + /// Comandos que siguen corriendo. + pub fn running(&self) -> Vec { + self.history + .iter() + .filter(|r| r.is_running()) + .map(|r| r.id) + .collect() + } + + /// Vacía el historial (no toca los grupos ni el `cwd`). + pub fn clear_history(&mut self) { + self.history.clear(); + } + + // --- Grupos reutilizables --- + + /// Guarda un grupo de comandos. Si ya existe uno con ese nombre, lo + /// reemplaza. + pub fn save_group(&mut self, name: impl Into, lines: Vec) { + let name = name.into(); + self.groups.retain(|g| g.name != name); + self.groups.push(CommandGroup { name, lines }); + } + + /// Guarda como grupo las líneas de los últimos `n` comandos del + /// historial — la forma natural de "convertir lo que acabo de hacer + /// en una receta". + pub fn save_recent_as_group(&mut self, name: impl Into, n: usize) { + let lines: Vec = self + .history + .iter() + .rev() + .take(n) + .rev() + .map(|r| r.line.clone()) + .collect(); + self.save_group(name, lines); + } + + pub fn groups(&self) -> &[CommandGroup] { + &self.groups + } + + pub fn group(&self, name: &str) -> Option<&CommandGroup> { + self.groups.iter().find(|g| g.name == name) + } + + /// Quita un grupo. `true` si existía. + pub fn remove_group(&mut self, name: &str) -> bool { + let before = self.groups.len(); + self.groups.retain(|g| g.name != name); + self.groups.len() != before + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn isolation_id_follows_the_directory() { + let mut s = WorkSession::new("trabajo", "/home/sergio/brahman"); + let id_a = s.isolation_id(); + s.set_cwd("/tmp"); + let id_b = s.isolation_id(); + assert_ne!(id_a, id_b, "cd cambia el contexto de aislamiento"); + // Estable: el mismo directorio da el mismo id. + s.set_cwd("/home/sergio/brahman"); + assert_eq!(s.isolation_id(), id_a); + } + + #[test] + fn a_run_records_its_directory() { + let mut s = WorkSession::new("t", "/home"); + let id = s.begin_run("ls -la", 1000); + assert_eq!(s.run(id).unwrap().cwd, "/home"); + assert_eq!(s.run(id).unwrap().status, RunStatus::Running); + } + + #[test] + fn output_accumulates_and_run_finishes() { + let mut s = WorkSession::new("t", "/home"); + let id = s.begin_run("echo hola", 1000); + s.append_output(id, Stream::Stdout, "hola"); + s.finish_run(id, 0, 1001); + let r = s.run(id).unwrap(); + assert_eq!(r.line_count(), 1); + assert_eq!(r.lines_of(Stream::Stdout).collect::>(), vec!["hola"]); + assert_eq!(r.status, RunStatus::Ok); + assert_eq!(r.exit_code, Some(0)); + assert_eq!(r.finished_at, Some(1001)); + } + + #[test] + fn output_separates_stdout_from_stderr() { + let mut s = WorkSession::new("t", "/home"); + let id = s.begin_run("build", 0); + s.append_output(id, Stream::Stdout, "compilando…"); + s.append_output(id, Stream::Stderr, "warning: variable sin usar"); + s.append_output(id, Stream::Stdout, "listo"); + let r = s.run(id).unwrap(); + assert!(r.has_stderr()); + assert_eq!(r.count_of(Stream::Stdout), 2); + assert_eq!(r.count_of(Stream::Stderr), 1); + assert_eq!( + r.lines_of(Stream::Stderr).collect::>(), + vec!["warning: variable sin usar"] + ); + } + + #[test] + fn nonzero_exit_marks_failed() { + let mut s = WorkSession::new("t", "/home"); + let id = s.begin_run("false", 0); + s.finish_run(id, 1, 1); + assert_eq!(s.run(id).unwrap().status, RunStatus::Failed); + } + + #[test] + fn running_lists_unfinished_commands() { + let mut s = WorkSession::new("t", "/home"); + let a = s.begin_run("sleep 1", 0); + let b = s.begin_run("sleep 2", 0); + s.finish_run(a, 0, 1); + assert_eq!(s.running(), vec![b]); + } + + #[test] + fn save_and_recall_a_group() { + let mut s = WorkSession::new("t", "/home"); + s.save_group("deploy", vec!["cargo build".into(), "scp target host:/srv".into()]); + assert_eq!(s.group("deploy").unwrap().lines.len(), 2); + // Guardar con el mismo nombre reemplaza. + s.save_group("deploy", vec!["echo nuevo".into()]); + assert_eq!(s.group("deploy").unwrap().lines, vec!["echo nuevo"]); + } + + #[test] + fn save_recent_history_as_a_group() { + let mut s = WorkSession::new("t", "/home"); + for line in ["git add .", "git commit", "git push"] { + s.begin_run(line, 0); + } + s.save_recent_as_group("publicar", 2); + // Los 2 últimos, en orden cronológico. + assert_eq!(s.group("publicar").unwrap().lines, vec!["git commit", "git push"]); + } + + #[test] + fn remove_group() { + let mut s = WorkSession::new("t", "/home"); + s.save_group("x", vec!["echo x".into()]); + assert!(s.remove_group("x")); + assert!(!s.remove_group("x")); + } + + #[test] + fn capture_policy_is_per_session() { + let mut s = WorkSession::new("t", "/home"); + assert_eq!(s.capture(), CapturePolicy::default()); + s.set_capture_limit(1_000_000); + s.set_spill(true); + assert_eq!(s.capture().limit_bytes, 1_000_000); + assert!(s.capture().spill); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-shell-render/Cargo.toml b/02_ruway/shuma/sandbox/shuma-shell-render/Cargo.toml new file mode 100644 index 0000000..515bc54 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-shell-render/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "shuma-shell-render" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — draw-plan agnóstico del Lienzo de Contexto: layout del grafo de intenciones (columnas por dependencia) + paint contra pineal-render." + +[dependencies] +shuma-intent = { path = "../shuma-intent" } +pineal-render = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-shell-render/LEEME.md b/02_ruway/shuma/sandbox/shuma-shell-render/LEEME.md new file mode 100644 index 0000000..575b03c --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-shell-render/LEEME.md @@ -0,0 +1,9 @@ +# shuma-shell-render + +> Renderer de output (ANSI, imágenes, links) de [shuma](../../README.md). + +Convierte un `Output` en `View`. ANSI escape codes → colores; URLs → links clickables; imágenes inline (PNG/JPEG/SVG). + +## Deps + +- [`shuma-core`](../shuma-core/README.md), [`llimphi-ui`](../../../llimphi/) diff --git a/02_ruway/shuma/sandbox/shuma-shell-render/README.md b/02_ruway/shuma/sandbox/shuma-shell-render/README.md new file mode 100644 index 0000000..370827d --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-shell-render/README.md @@ -0,0 +1,9 @@ +# shuma-shell-render + +> Output renderer (ANSI, images, links) of [shuma](../../README.md). + +Converts an `Output` to `View`. ANSI escape codes → real colors; URLs → clickable links; inline images (PNG/JPEG/SVG). + +## Deps + +- [`shuma-core`](../shuma-core/README.md), [`llimphi-ui`](../../../llimphi/) diff --git a/02_ruway/shuma/sandbox/shuma-shell-render/src/lib.rs b/02_ruway/shuma/sandbox/shuma-shell-render/src/lib.rs new file mode 100644 index 0000000..7237de4 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-shell-render/src/lib.rs @@ -0,0 +1,220 @@ +//! `shuma-shell-render` — draw-plan agnóstico del Lienzo de Contexto. +//! +//! Toma un [`SessionGraph`] y computa el layout del grafo de intenciones: +//! cada comando `%cN` es una caja, ubicada en una columna según su +//! profundidad de dependencia (longest-path); cada referencia `%pN`/`%cN` +//! que un comando consume es una arista hacia el comando que la produjo. +//! +//! Agnóstico de UI: el front-end GPUI consume el [`CanvasPlan`] y lo +//! dibuja; [`paint`] ofrece un render directo contra `pineal_render`. + +#![forbid(unsafe_code)] + +use pineal_render::{Canvas, Color, Point, Rect, StrokeStyle}; +use shuma_intent::{Intention, NodeStatus, SessionGraph}; + +/// Una caja de comando ya posicionada en el lienzo. +#[derive(Debug, Clone)] +pub struct NodeBox { + pub command_id: u32, + /// Texto de la intención (el caller lo trunca al dibujar si hace falta). + pub label: String, + pub status: NodeStatus, + pub collapsed: bool, + pub column: usize, + pub rect: Rect, +} + +/// Una arista del lienzo: flujo de datos entre dos comandos. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Edge { + pub from_command: u32, + pub to_command: u32, + /// `%pN` si el flujo va por un buffer; `None` si es una ref a comando. + pub buffer_id: Option, +} + +/// El layout completo del lienzo. +#[derive(Debug, Clone, Default)] +pub struct CanvasPlan { + pub nodes: Vec, + pub edges: Vec, +} + +impl CanvasPlan { + /// Caja de un comando por su id. + pub fn node(&self, command_id: u32) -> Option<&NodeBox> { + self.nodes.iter().find(|n| n.command_id == command_id) + } +} + +/// Parámetros geométricos del layout. +#[derive(Debug, Clone, Copy)] +pub struct LayoutParams { + pub node_w: f32, + pub node_h: f32, + pub collapsed_h: f32, + pub col_gap: f32, + pub row_gap: f32, + pub origin: Point, +} + +impl Default for LayoutParams { + fn default() -> Self { + Self { + node_w: 160.0, + node_h: 56.0, + collapsed_h: 22.0, + col_gap: 64.0, + row_gap: 20.0, + origin: Point::new(16.0, 16.0), + } + } +} + +/// Computa el layout del grafo de intenciones. +pub fn layout(graph: &SessionGraph, p: &LayoutParams) -> CanvasPlan { + let cmds = graph.commands(); + let mut edges = Vec::new(); + // Profundidad de cada comando por su id (los comandos sólo refieren + // resultados previos, así que recorrer en orden de id basta). + let mut depth: Vec<(u32, usize)> = Vec::with_capacity(cmds.len()); + + for c in cmds { + let refs = Intention::parse(&c.intention).refs(); + let mut d = 0usize; + for r in refs { + if let Some(producer) = graph.resolve(r) { + edges.push(Edge { + from_command: producer.id, + to_command: c.id, + buffer_id: producer.output_buffer, + }); + let pd = depth + .iter() + .find(|(id, _)| *id == producer.id) + .map(|(_, d)| *d) + .unwrap_or(0); + d = d.max(pd + 1); + } + } + depth.push((c.id, d)); + } + + // Posiciona: columna = profundidad, fila = orden de llegada en la columna. + let mut rows_in_col: Vec = Vec::new(); + let mut nodes = Vec::with_capacity(cmds.len()); + for (c, &(_, col)) in cmds.iter().zip(&depth) { + while rows_in_col.len() <= col { + rows_in_col.push(0); + } + let row = rows_in_col[col]; + rows_in_col[col] += 1; + + let h = if c.collapsed { p.collapsed_h } else { p.node_h }; + let x = p.origin.x + col as f32 * (p.node_w + p.col_gap); + let y = p.origin.y + row as f32 * (p.node_h + p.row_gap); + nodes.push(NodeBox { + command_id: c.id, + label: c.intention.clone(), + status: c.status, + collapsed: c.collapsed, + column: col, + rect: Rect::new(x, y, p.node_w, h), + }); + } + CanvasPlan { nodes, edges } +} + +/// Color de borde según el estado del nodo. +fn status_color(s: NodeStatus) -> Color { + match s { + NodeStatus::Running => Color::from_hex(0xe0b341), // ámbar + NodeStatus::Ok => Color::from_hex(0x4caf6a), // verde + NodeStatus::Failed => Color::from_hex(0xd0463b), // rojo + } +} + +/// Dibuja el plan contra un `Canvas`: aristas primero (al fondo), luego +/// las cajas de comando. El texto lo dibuja el caller si quiere control +/// de truncado/fuente. +pub fn paint(plan: &CanvasPlan, canvas: &mut dyn Canvas) { + let edge_stroke = StrokeStyle::new(1.5, Color::from_hex(0x6b7280)); + for e in &plan.edges { + let (Some(a), Some(b)) = (plan.node(e.from_command), plan.node(e.to_command)) + else { + continue; + }; + let from = Point::new(a.rect.right(), a.rect.y + a.rect.h / 2.0); + let to = Point::new(b.rect.x, b.rect.y + b.rect.h / 2.0); + canvas.stroke_line(from, to, edge_stroke); + } + for n in &plan.nodes { + canvas.fill_rect(n.rect, Color::from_hex(0x1c2128)); + canvas.stroke_rect(n.rect, StrokeStyle::new(2.0, status_color(n.status))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Sesión: c1 produce %p1; c2 lo consume. + fn chained_session() -> SessionGraph { + let mut g = SessionGraph::new(); + let c1 = g.record("cat data.json"); + g.complete(c1, true, 2400); // → %p1 + let _c2 = g.record("sort | %p1"); + g + } + + #[test] + fn layout_places_dependent_in_a_later_column() { + let g = chained_session(); + let plan = layout(&g, &LayoutParams::default()); + assert_eq!(plan.nodes.len(), 2); + let c1 = plan.node(1).unwrap(); + let c2 = plan.node(2).unwrap(); + assert_eq!(c1.column, 0); + assert_eq!(c2.column, 1, "el que consume %p1 va una columna después"); + assert!(c2.rect.x > c1.rect.x); + } + + #[test] + fn layout_creates_an_edge_for_the_buffer_flow() { + let g = chained_session(); + let plan = layout(&g, &LayoutParams::default()); + assert_eq!(plan.edges.len(), 1); + assert_eq!(plan.edges[0].from_command, 1); + assert_eq!(plan.edges[0].to_command, 2); + assert_eq!(plan.edges[0].buffer_id, Some(1)); + } + + #[test] + fn independent_commands_share_column_zero() { + let mut g = SessionGraph::new(); + g.record("ls"); + g.record("pwd"); + let plan = layout(&g, &LayoutParams::default()); + assert!(plan.nodes.iter().all(|n| n.column == 0)); + assert!(plan.edges.is_empty()); + // Apiladas en filas distintas. + assert_ne!(plan.nodes[0].rect.y, plan.nodes[1].rect.y); + } + + #[test] + fn paint_emits_commands_to_a_recorder() { + use pineal_render::{PlanRecorder, RenderCmd}; + let g = chained_session(); + let plan = layout(&g, &LayoutParams::default()); + let mut rec = PlanRecorder::new(); + paint(&plan, &mut rec); + let cmds = rec.into_plan().cmds; + // 1 línea (la arista) + 2 fill + 2 stroke (las cajas). + assert!(cmds.iter().any(|c| matches!(c, RenderCmd::StrokeLine { .. }))); + assert_eq!( + cmds.iter().filter(|c| matches!(c, RenderCmd::FillRect { .. })).count(), + 2 + ); + } +} diff --git a/02_ruway/shuma/sandbox/shuma-sysmon/Cargo.toml b/02_ruway/shuma/sandbox/shuma-sysmon/Cargo.toml new file mode 100644 index 0000000..eed9c37 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-sysmon/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shuma-sysmon" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma — muestreo de CPU y memoria con historial para los monitores del shell. Parseo de /proc separado del cálculo, agnóstico de UI." + +[dependencies] +serde = { workspace = true } diff --git a/02_ruway/shuma/sandbox/shuma-sysmon/LEEME.md b/02_ruway/shuma/sandbox/shuma-sysmon/LEEME.md new file mode 100644 index 0000000..08299fd --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-sysmon/LEEME.md @@ -0,0 +1,9 @@ +# shuma-sysmon + +> Monitor de sistema embebido de [shuma](../../README.md). + +Lee /proc periódicamente y publica CPU/mem/disk/net en un topic [`chasqui`](../../../chasqui/README.md). Suscribible desde cualquier app. + +## Deps + +- [`chasqui-core`](../../../chasqui/chasqui-core/README.md), `sysinfo` diff --git a/02_ruway/shuma/sandbox/shuma-sysmon/README.md b/02_ruway/shuma/sandbox/shuma-sysmon/README.md new file mode 100644 index 0000000..7472f30 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-sysmon/README.md @@ -0,0 +1,9 @@ +# shuma-sysmon + +> Embedded system monitor of [shuma](../../README.md). + +Reads /proc periodically and publishes CPU/mem/disk/net to a [`chasqui`](../../../chasqui/README.md) topic. Subscribable from any app. + +## Deps + +- [`chasqui-core`](../../../chasqui/chasqui-core/README.md), `sysinfo` diff --git a/02_ruway/shuma/sandbox/shuma-sysmon/src/lib.rs b/02_ruway/shuma/sandbox/shuma-sysmon/src/lib.rs new file mode 100644 index 0000000..4b518f7 --- /dev/null +++ b/02_ruway/shuma/sandbox/shuma-sysmon/src/lib.rs @@ -0,0 +1,297 @@ +//! `shuma-sysmon` — muestreo de CPU y memoria para los monitores del shell. +//! +//! Lee `/proc/stat` y `/proc/meminfo`, calcula el porcentaje de uso de +//! CPU (delta entre dos muestras) y de memoria, y mantiene un historial +//! corto para dibujar la curva del monitor. +//! +//! El parseo de `/proc` está separado del cálculo: las funciones puras +//! [`parse_cpu_stat`] y [`parse_meminfo`] se prueban con texto fijo, y +//! [`SystemSampler::sample`] sólo añade la lectura de archivos. Así la +//! lógica es testeable sin depender del sistema y el crate es agnóstico +//! de cualquier frontend. + +#![forbid(unsafe_code)] + +use std::collections::VecDeque; + +use serde::{Deserialize, Serialize}; + +/// Acumuladores de CPU de `/proc/stat` — tiempo ocupado y total. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CpuStat { + pub busy: u64, + pub total: u64, +} + +/// Memoria de `/proc/meminfo`, en kibibytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MemStat { + pub total_kb: u64, + pub available_kb: u64, +} + +/// Parsea la línea agregada `cpu` de `/proc/stat`. El uso instantáneo no +/// se puede sacar de una sola muestra — hace falta el delta entre dos. +pub fn parse_cpu_stat(text: &str) -> Option { + let line = text.lines().find(|l| { + l.starts_with("cpu") && l[3..].starts_with(char::is_whitespace) + })?; + let fields: Vec = line + .split_whitespace() + .skip(1) // la etiqueta "cpu" + .filter_map(|f| f.parse().ok()) + .collect(); + if fields.len() < 4 { + return None; + } + // Campos: user nice system idle iowait irq softirq steal … + let total: u64 = fields.iter().sum(); + let idle = fields[3] + fields.get(4).copied().unwrap_or(0); // idle + iowait + Some(CpuStat { busy: total.saturating_sub(idle), total }) +} + +/// Parsea `MemTotal` y `MemAvailable` de `/proc/meminfo`. +pub fn parse_meminfo(text: &str) -> Option { + let field = |key: &str| -> Option { + text.lines() + .find(|l| l.starts_with(key))? + .split_whitespace() + .nth(1)? + .parse() + .ok() + }; + Some(MemStat { + total_kb: field("MemTotal:")?, + available_kb: field("MemAvailable:")?, + }) +} + +/// Un historial circular de valores `f32` para dibujar una curva. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct History { + samples: VecDeque, + capacity: usize, +} + +impl History { + /// Historial vacío con capacidad para `capacity` muestras. + pub fn new(capacity: usize) -> Self { + Self { samples: VecDeque::with_capacity(capacity.max(1)), capacity: capacity.max(1) } + } + + /// Añade una muestra; descarta la más antigua si se llena. + pub fn push(&mut self, value: f32) { + if self.samples.len() == self.capacity { + self.samples.pop_front(); + } + self.samples.push_back(value); + } + + /// Muestras de la más antigua a la más reciente. + pub fn values(&self) -> Vec { + self.samples.iter().copied().collect() + } + + /// Muestra más reciente. + pub fn last(&self) -> Option { + self.samples.back().copied() + } + + pub fn len(&self) -> usize { + self.samples.len() + } + + pub fn is_empty(&self) -> bool { + self.samples.is_empty() + } + + pub fn capacity(&self) -> usize { + self.capacity + } +} + +/// Una lectura del estado del sistema en un instante. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Snapshot { + /// Uso de CPU, `0.0..=100.0`. + pub cpu_percent: f32, + /// Uso de memoria, `0.0..=100.0`. + pub mem_percent: f32, + pub mem_used_mb: u64, + pub mem_total_mb: u64, + /// `false` si no se pudo leer `/proc` (p. ej. fuera de Linux). + pub valid: bool, +} + +impl Snapshot { + fn invalid() -> Self { + Self { cpu_percent: 0.0, mem_percent: 0.0, mem_used_mb: 0, mem_total_mb: 0, valid: false } + } +} + +/// Muestreador del sistema: guarda la muestra de CPU anterior (para el +/// delta) y el historial de ambas curvas. +#[derive(Debug, Clone)] +pub struct SystemSampler { + prev_cpu: Option, + cpu_history: History, + mem_history: History, +} + +impl SystemSampler { + /// Crea un muestreador cuyas curvas guardan `history` muestras. + pub fn new(history: usize) -> Self { + Self { + prev_cpu: None, + cpu_history: History::new(history), + mem_history: History::new(history), + } + } + + /// Calcula un `Snapshot` a partir del texto de `/proc/stat` y + /// `/proc/meminfo`. Es la parte pura — `sample` sólo le añade la + /// lectura de archivos. + pub fn sample_from(&mut self, stat_text: &str, meminfo_text: &str) -> Snapshot { + let (Some(cpu), Some(mem)) = + (parse_cpu_stat(stat_text), parse_meminfo(meminfo_text)) + else { + return Snapshot::invalid(); + }; + + // El uso de CPU es el delta de ocupación entre dos muestras. + let cpu_percent = match self.prev_cpu { + Some(prev) => { + let total_delta = cpu.total.saturating_sub(prev.total); + let busy_delta = cpu.busy.saturating_sub(prev.busy); + if total_delta == 0 { + self.cpu_history.last().unwrap_or(0.0) + } else { + (busy_delta as f32 / total_delta as f32 * 100.0).clamp(0.0, 100.0) + } + } + None => 0.0, // primera muestra: aún no hay delta + }; + self.prev_cpu = Some(cpu); + + let used_kb = mem.total_kb.saturating_sub(mem.available_kb); + let mem_percent = if mem.total_kb == 0 { + 0.0 + } else { + (used_kb as f32 / mem.total_kb as f32 * 100.0).clamp(0.0, 100.0) + }; + + self.cpu_history.push(cpu_percent); + self.mem_history.push(mem_percent); + + Snapshot { + cpu_percent, + mem_percent, + mem_used_mb: used_kb / 1024, + mem_total_mb: mem.total_kb / 1024, + valid: true, + } + } + + /// Lee `/proc` y produce un `Snapshot`. Fuera de Linux, o si `/proc` + /// no está disponible, devuelve un snapshot `valid: false`. + pub fn sample(&mut self) -> Snapshot { + let stat = std::fs::read_to_string("/proc/stat"); + let meminfo = std::fs::read_to_string("/proc/meminfo"); + match (stat, meminfo) { + (Ok(s), Ok(m)) => self.sample_from(&s, &m), + _ => Snapshot::invalid(), + } + } + + /// Historial de uso de CPU (curva del monitor). + pub fn cpu_history(&self) -> &History { + &self.cpu_history + } + + /// Historial de uso de memoria. + pub fn mem_history(&self) -> &History { + &self.mem_history + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STAT_1: &str = "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 50 0 25 400 25 0 0\n"; + // 100 jiffies de ocupación más que STAT_1, 100 de inactividad más. + const STAT_2: &str = "cpu 150 0 100 850 100 0 0 0 0 0\ncpu0 75 0 50 425 50 0 0\n"; + const MEMINFO: &str = + "MemTotal: 16000000 kB\nMemFree: 2000000 kB\nMemAvailable: 4000000 kB\n"; + + #[test] + fn parses_cpu_aggregate_line() { + let c = parse_cpu_stat(STAT_1).unwrap(); + // total = 100+0+50+800+50 = 1000; idle = 800+50 = 850; busy = 150. + assert_eq!(c.total, 1000); + assert_eq!(c.busy, 150); + } + + #[test] + fn parses_meminfo() { + let m = parse_meminfo(MEMINFO).unwrap(); + assert_eq!(m.total_kb, 16_000_000); + assert_eq!(m.available_kb, 4_000_000); + } + + #[test] + fn rejects_malformed_proc_text() { + assert!(parse_cpu_stat("garbage").is_none()); + assert!(parse_meminfo("MemTotal: only").is_none()); + } + + #[test] + fn first_sample_has_zero_cpu_then_delta() { + let mut s = SystemSampler::new(60); + let first = s.sample_from(STAT_1, MEMINFO); + assert_eq!(first.cpu_percent, 0.0); // sin muestra previa + assert!(first.valid); + + let second = s.sample_from(STAT_2, MEMINFO); + // total_delta: 1200-1000=200; busy_delta: STAT_2 busy = 150+0+100=250 + // total2 = 150+0+100+850+100 = 1200; idle2 = 950; busy2 = 250. + // busy_delta = 250-150 = 100; cpu% = 100/200 = 50%. + assert!((second.cpu_percent - 50.0).abs() < 0.01); + } + + #[test] + fn memory_percent_uses_available() { + let mut s = SystemSampler::new(60); + let snap = s.sample_from(STAT_1, MEMINFO); + // used = 16000000 - 4000000 = 12000000 kB → 75%. + assert!((snap.mem_percent - 75.0).abs() < 0.01); + assert_eq!(snap.mem_total_mb, 16_000_000 / 1024); + } + + #[test] + fn invalid_proc_yields_invalid_snapshot() { + let mut s = SystemSampler::new(60); + let snap = s.sample_from("nonsense", "nonsense"); + assert!(!snap.valid); + } + + #[test] + fn history_fills_both_curves() { + let mut s = SystemSampler::new(60); + s.sample_from(STAT_1, MEMINFO); + s.sample_from(STAT_2, MEMINFO); + assert_eq!(s.cpu_history().len(), 2); + assert_eq!(s.mem_history().len(), 2); + } + + #[test] + fn history_is_a_bounded_ring() { + let mut h = History::new(3); + for v in [1.0, 2.0, 3.0, 4.0, 5.0] { + h.push(v); + } + assert_eq!(h.len(), 3); + assert_eq!(h.values(), vec![3.0, 4.0, 5.0]); // las 3 más recientes + assert_eq!(h.last(), Some(5.0)); + } +} diff --git a/02_ruway/shuma/shuma-cli/Cargo.toml b/02_ruway/shuma/shuma-cli/Cargo.toml new file mode 100644 index 0000000..fdc859e --- /dev/null +++ b/02_ruway/shuma/shuma-cli/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "shuma-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "CLI de administración de shipote-daemon." + +[[bin]] +name = "shuma" +path = "src/main.rs" + +[dependencies] +shuma-card = { path = "../sandbox/shuma-card" } +shuma-protocol = { path = "../sandbox/shuma-protocol" } +card-core = { workspace = true } +anyhow = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +serde_json = { workspace = true } +ulid = { workspace = true } diff --git a/02_ruway/shuma/shuma-cli/LEEME.md b/02_ruway/shuma/shuma-cli/LEEME.md new file mode 100644 index 0000000..664ed3f --- /dev/null +++ b/02_ruway/shuma/shuma-cli/LEEME.md @@ -0,0 +1,15 @@ +# shuma-cli + +> CLI puro de [shuma](../README.md). Sin UI Llimphi. + +Modo terminal estándar: lee stdin, escribe stdout. Sirve para uso por SSH o cuando no hay Llimphi disponible. + +## Uso + +```sh +cargo run --release -p shuma-cli +``` + +## Deps + +- [`shuma-core`](../shuma-core/README.md), [`shuma-line`](../shuma-line/README.md), [`shuma-exec`](../shuma-exec/README.md) diff --git a/02_ruway/shuma/shuma-cli/README.md b/02_ruway/shuma/shuma-cli/README.md new file mode 100644 index 0000000..789c24e --- /dev/null +++ b/02_ruway/shuma/shuma-cli/README.md @@ -0,0 +1,15 @@ +# shuma-cli + +> Pure CLI of [shuma](../README.md). No Llimphi UI. + +Standard terminal mode: reads stdin, writes stdout. For use over SSH or when no Llimphi available. + +## Usage + +```sh +cargo run --release -p shuma-cli +``` + +## Deps + +- [`shuma-core`](../sandbox/shuma-core/README.md), [`shuma-line`](../sandbox/shuma-line/README.md), [`shuma-exec`](../sandbox/shuma-exec/README.md) diff --git a/02_ruway/shuma/shuma-cli/src/main.rs b/02_ruway/shuma/shuma-cli/src/main.rs new file mode 100644 index 0000000..d644ecb --- /dev/null +++ b/02_ruway/shuma/shuma-cli/src/main.rs @@ -0,0 +1,740 @@ +//! `shuma` — CLI de administración del daemon. + +use anyhow::{anyhow, Context, Result}; +use clap::{Parser, Subcommand}; +use shuma_card::{load_pipeline_spec, load_workspace_spec, WorkspaceId}; +use shuma_protocol::{default_socket_path, read_frame, write_frame, Request, Response}; +use std::path::PathBuf; +use tokio::net::UnixStream; +use ulid::Ulid; + +#[derive(Parser, Debug)] +#[command(name = "shuma", version, about = "Administración de shuma-daemon")] +struct Cli { + /// Path al socket del daemon. Default: $XDG_RUNTIME_DIR/shuma.sock. + #[arg(long, global = true)] + socket: Option, + + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Health-check del daemon. + Ping, + + /// Health endpoint estructurado. + Health, + + /// Capacidades runtime detectadas por el daemon. + Caps, + + /// Operaciones sobre Workspaces. + #[command(subcommand)] + Workspace(WsCmd), + + /// Ejecutar un comando one-shot dentro de un workspace. + Run { + /// ULID del workspace destino. + #[arg(short = 'w', long)] + workspace: String, + /// Si exit != 0, relanzar con backoff exponencial. + #[arg(long)] + restart_on_failure: bool, + /// Path del ejecutable. + exec: String, + /// Argumentos del comando. + argv: Vec, + }, + + /// Discernir el tipo de un archivo (ad-hoc, sin workspace). + Discern { + /// Path al archivo a discernir. + path: PathBuf, + }, + + /// Listar comandos de un workspace. + Commands { + /// ULID del workspace. + workspace: String, + }, + + /// Mostrar tail del log capturado de un comando. + Logs { + /// ULID del workspace. + workspace: String, + /// ULID del comando. + command: String, + /// Bytes desde el final (0 = todo). + #[arg(long, default_value_t = 0)] + tail: usize, + /// Stream a leer: stdout | stderr | both. + #[arg(long, default_value = "both")] + stream: String, + /// Seguir el log en vivo (poll cada 200ms hasta que el comando termine). + #[arg(short = 'f', long)] + follow: bool, + }, + + /// Pipeline DAG con flujo tipado. + #[command(subcommand)] + Pipeline(PipeCmd), + + /// Flow data plane (subscribirse a streams enriquecidos). + #[command(subcommand)] + Flow(FlowCmd), +} + +#[derive(Subcommand, Debug)] +enum FlowCmd { + /// Listar pipelines activos con sus sockets de flow. + List, + /// Throughput por flow socket (bytes_total + bytes/s). + Throughput, + /// Cerrar el data plane de un pipeline (drop de todos sus sockets). + Drop { pipeline: String }, + /// Suscribirse a un flow socket y volcar bytes a stdout. + Tail { + /// Path al Unix socket del flow. + socket: PathBuf, + }, +} + +#[derive(Subcommand, Debug)] +enum PipeCmd { + /// Lanzar un Pipeline desde un spec TOML/JSON. + Run { + /// Path al spec del pipeline. + spec: PathBuf, + /// Interponer un tap entre productor↔consumidor de cada edge para + /// discernir el TypeRef del flujo. + #[arg(long)] + tap: bool, + /// Variables `KEY=VALUE` para sustitución `${KEY}` en el spec. + #[arg(long = "var", value_parser = parse_kv)] + vars: Vec<(String, String)>, + /// Tras lanzar, suscribir al primer flow socket y volcar bytes + /// a stdout hasta EOF. Implica `--tap`. + #[arg(long)] + tail: bool, + }, + /// Guardar un pipeline bajo un nombre (persiste con el snapshot). + Save { + /// Nombre simbólico. + name: String, + /// Path al spec. + spec: PathBuf, + }, + /// Listar nombres de pipelines guardados. + SavedList, + /// Eliminar un pipeline guardado (no afecta runs en curso). + Drop { name: String }, + /// Detener un pipeline en curso por ID (SIGTERM → grace → SIGKILL + /// sólo a sus comandos). + Stop { + /// ULID del pipeline (devuelto por `pipeline run`). + pipeline: String, + #[arg(long, default_value_t = 1000)] + grace_ms: u64, + }, + /// Ejecutar un pipeline guardado por nombre. + RunSaved { + name: String, + #[arg(long)] + tap: bool, + #[arg(long = "var", value_parser = parse_kv)] + vars: Vec<(String, String)>, + #[arg(long)] + tail: bool, + }, +} + +fn parse_kv(s: &str) -> Result<(String, String), String> { + let (k, v) = s.split_once('=').ok_or_else(|| format!("expected KEY=VALUE, got `{s}`"))?; + Ok((k.to_string(), v.to_string())) +} + +#[derive(Subcommand, Debug)] +enum WsCmd { + /// Crear un workspace desde un spec TOML/JSON. + Create { + /// Path al spec del workspace. + spec: PathBuf, + }, + /// Listar workspaces vivos. + List, + /// Detener un workspace por ID. + Stop { + id: String, + /// Milisegundos de gracia tras SIGTERM antes de SIGKILL. + #[arg(long, default_value_t = 1000)] + grace_ms: u64, + }, + /// Resource accounting (RSS, CPU, comandos vivos). + Stats { + id: String, + }, + /// Quota report: rlimits declarados vs uso actual. + Quota { + id: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let socket = cli.socket.unwrap_or_else(default_socket_path); + let mut stream = UnixStream::connect(&socket) + .await + .with_context(|| format!("connect {}", socket.display()))?; + + match cli.cmd { + Cmd::Ping => { + let resp = round_trip(&mut stream, Request::Ping).await?; + match resp { + Response::Pong => println!("pong"), + other => print_unexpected(&other), + } + } + + Cmd::Health => { + let resp = round_trip(&mut stream, Request::Health).await?; + match resp { + Response::Health { + version, + uptime_ms, + alive_workspaces, + alive_commands, + alive_pipelines, + active_flows, + dirty, + } => { + println!("version: {version}"); + println!("uptime: {} ms", uptime_ms); + println!("alive_workspaces: {alive_workspaces}"); + println!("alive_commands: {alive_commands}"); + println!("alive_pipelines: {alive_pipelines}"); + println!("active_flows: {active_flows}"); + println!("dirty: {dirty}"); + } + other => print_unexpected(&other), + } + } + + Cmd::Caps => { + let resp = round_trip(&mut stream, Request::Capabilities).await?; + match resp { + Response::Capabilities { + kernel_version, + user_ns, + cgroup_v2, + cgroup_delegated, + has_cap_sys_admin, + } => { + println!("kernel: {}.{}.{}", kernel_version.0, kernel_version.1, kernel_version.2); + println!("user_ns: {user_ns}"); + println!("cgroup_v2: {cgroup_v2}"); + println!("cgroup_delegated: {cgroup_delegated}"); + println!("cap_sys_admin: {has_cap_sys_admin}"); + } + other => print_unexpected(&other), + } + } + + Cmd::Workspace(WsCmd::Create { spec }) => { + let ws = load_workspace_spec(&spec).with_context(|| format!("load {}", spec.display()))?; + let resp = round_trip(&mut stream, Request::WorkspaceCreate { spec: ws }).await?; + match resp { + Response::WorkspaceCreated { id, warnings } => { + println!("{id}"); + for w in warnings { + eprintln!("warning: {w}"); + } + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Workspace(WsCmd::List) => { + let resp = round_trip(&mut stream, Request::WorkspaceList).await?; + match resp { + Response::WorkspaceList { items } => { + if items.is_empty() { + println!("(no workspaces)"); + } + for it in items { + println!( + "{} {:<20} cmds={} uptime={}ms", + it.id, it.label, it.commands, it.uptime_ms + ); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Workspace(WsCmd::Stats { id }) => { + let id = parse_ws_id(&id)?; + let resp = round_trip(&mut stream, Request::WorkspaceStats { workspace: id }).await?; + match resp { + Response::WorkspaceStats { info } => { + println!("commands: {} alive / {} total", info.commands_alive, info.commands_total); + let fmt_mib = |b: u64| format!("{:.2} MiB", b as f64 / 1024.0 / 1024.0); + let rss = info.rss_bytes.map(fmt_mib).unwrap_or_else(|| "—".into()); + let peak = info.rss_peak_bytes.map(fmt_mib).unwrap_or_else(|| "—".into()); + let cpu = info + .cpu_usec + .map(|u| format!("{:.3} s", u as f64 / 1_000_000.0)) + .unwrap_or_else(|| "—".into()); + let cpu_pct = info + .cpu_percent + .map(|p| format!("{p:.1} % ({:.1}% total / {} cores)", + if info.cpu_cores > 0 { p / info.cpu_cores as f32 } else { p }, + info.cpu_cores)) + .unwrap_or_else(|| "— (esperando 2do sample)".into()); + println!("rss: {rss}"); + println!("rss_peak: {peak}"); + println!("cpu: {cpu}"); + println!("cpu_pct: {cpu_pct}"); + println!("source: {}", info.source); + println!("uptime: {} ms", info.uptime_ms); + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Workspace(WsCmd::Quota { id }) => { + let id = parse_ws_id(&id)?; + let resp = round_trip(&mut stream, Request::WorkspaceQuota { workspace: id }).await?; + match resp { + Response::WorkspaceQuota { info } => { + let mem = info + .mem_limit + .map(|b| format!("{:.2} MiB", b as f64 / 1024.0 / 1024.0)) + .unwrap_or_else(|| "—".into()); + let nproc = info + .nproc_limit + .map(|n| n.to_string()) + .unwrap_or_else(|| "—".into()); + println!("mem_limit: {mem}"); + println!("nproc_limit: {nproc}"); + if info.breaches.is_empty() { + println!("breaches: (none — dentro de quota)"); + } else { + println!("breaches:"); + for b in info.breaches { + println!(" - {b}"); + } + } + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Workspace(WsCmd::Stop { id, grace_ms }) => { + let id = parse_ws_id(&id)?; + let resp = round_trip(&mut stream, Request::WorkspaceStop { id, grace_ms }).await?; + match resp { + Response::WorkspaceStopped { id, reaped } => { + println!("stopped {id} (reaped {reaped})"); + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Run { workspace, exec, argv, restart_on_failure } => { + let id = parse_ws_id(&workspace)?; + let resp = round_trip( + &mut stream, + Request::Run { + workspace: id, + exec, + argv, + envp: vec![], + restart_on_failure, + }, + ) + .await?; + match resp { + Response::RunStarted { command_id, pid, .. } => { + println!("{command_id} pid={pid}"); + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Pipeline(PipeCmd::Run { spec, tap, vars, tail }) => { + let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?; + // --tail implica --tap (no hay flow socket sin tap). + let effective_tap = tap || tail; + let resp = round_trip( + &mut stream, + Request::PipelineRun { + spec: p, + tap: effective_tap, + vars: vars.into_iter().collect(), + }, + ) + .await?; + let socket = print_pipeline_started_returning_socket(resp)?; + if tail { + if let Some(sock) = socket { + eprintln!("--- tailing {} ---", sock.display()); + tail_socket(&sock).await?; + } else { + eprintln!("--tail: no hay flow socket disponible"); + } + } + } + + Cmd::Pipeline(PipeCmd::Save { name, spec }) => { + let p = load_pipeline_spec(&spec).with_context(|| format!("load {}", spec.display()))?; + let resp = round_trip(&mut stream, Request::PipelineSave { name: name.clone(), spec: p }).await?; + match resp { + Response::PipelineSaved { name } => println!("saved {name}"), + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + + Cmd::Pipeline(PipeCmd::SavedList) => { + let resp = round_trip(&mut stream, Request::PipelineSavedList).await?; + match resp { + Response::PipelineSavedList { names } => { + if names.is_empty() { + println!("(no saved pipelines)"); + } + for n in names { + println!("{n}"); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Pipeline(PipeCmd::Stop { pipeline, grace_ms }) => { + let pid = Ulid::from_string(&pipeline).map_err(|e| anyhow!("invalid pipeline id: {e}"))?; + let resp = round_trip(&mut stream, Request::PipelineStop { pipeline: pid, grace_ms }).await?; + match resp { + Response::PipelineStopped { pipeline, reaped } => { + println!("stopped pipeline {pipeline} (reaped {reaped})"); + } + other => print_unexpected(&other), + } + } + + Cmd::Pipeline(PipeCmd::Drop { name }) => { + let resp = round_trip(&mut stream, Request::PipelineDrop { name }).await?; + match resp { + Response::PipelineDropped { name, existed } => { + if existed { + println!("dropped {name}"); + } else { + eprintln!("no existía: {name}"); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Pipeline(PipeCmd::RunSaved { name, tap, vars, tail }) => { + let effective_tap = tap || tail; + let resp = round_trip( + &mut stream, + Request::PipelineRunSaved { + name, + tap: effective_tap, + vars: vars.into_iter().collect(), + }, + ) + .await?; + let socket = print_pipeline_started_returning_socket(resp)?; + if tail { + if let Some(sock) = socket { + eprintln!("--- tailing {} ---", sock.display()); + tail_socket(&sock).await?; + } else { + eprintln!("--tail: no hay flow socket disponible"); + } + } + } + + Cmd::Commands { workspace } => { + let ws = parse_ws_id(&workspace)?; + let resp = round_trip(&mut stream, Request::CommandList { workspace: ws }).await?; + match resp { + Response::CommandList { items } => { + if items.is_empty() { + println!("(no commands)"); + } + for c in items { + let alive = if c.alive { "alive" } else { "exited" }; + let exit = c + .exit_status + .map(|s| format!("exit={s}")) + .unwrap_or_default(); + println!( + "{} {:<24} pid={:<7} {:<8} logs={} {}", + c.id, c.label, c.pid, alive, c.log_bytes, exit + ); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Logs { workspace, command, tail, stream: which_stream, follow } => { + let ws = parse_ws_id(&workspace)?; + let cmd_id = Ulid::from_string(&command).map_err(|e| anyhow!("invalid command id: {e}"))?; + if !follow { + let resp = round_trip( + &mut stream, + Request::CommandLogs { + workspace: ws, + command: cmd_id, + tail_bytes: tail, + stream: which_stream, + }, + ) + .await?; + match resp { + Response::CommandLogs { bytes } => { + use std::io::Write; + let _ = std::io::stdout().write_all(&bytes); + let _ = std::io::stdout().flush(); + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } else { + // Follow mode: poll cada 200ms. Mantenemos el último buffer + // visto; cada round imprimimos el delta (suffix nuevo). + // Limitación: si el ring rota más rápido que el poll, perdemos + // bytes — pero el comportamiento es "best effort". + use std::io::Write; + let mut prev: Vec = Vec::new(); + loop { + let resp = round_trip( + &mut stream, + Request::CommandLogs { + workspace: ws, + command: cmd_id, + tail_bytes: 0, + stream: which_stream.clone(), + }, + ) + .await?; + let bytes = match resp { + Response::CommandLogs { bytes } => bytes, + Response::Error { message } => return Err(anyhow!(message)), + other => { + print_unexpected(&other); + break; + } + }; + // Imprimir suffix nuevo si bytes es extension de prev. + if bytes.len() >= prev.len() && bytes[..prev.len()] == prev[..] { + let _ = std::io::stdout().write_all(&bytes[prev.len()..]); + } else { + // Ring rotó — reset y print todo. + let _ = std::io::stdout().write_all(&bytes); + } + let _ = std::io::stdout().flush(); + prev = bytes; + + // Si el comando terminó, salir tras un último read. + let list_resp = round_trip( + &mut stream, + Request::CommandList { workspace: ws }, + ) + .await?; + let mut still_alive = false; + if let Response::CommandList { items } = list_resp { + if let Some(c) = items.iter().find(|c| c.id == cmd_id) { + still_alive = c.alive; + } + } + if !still_alive { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + } + } + + Cmd::Flow(FlowCmd::List) => { + let resp = round_trip(&mut stream, Request::FlowList).await?; + match resp { + Response::FlowList { items } => { + if items.is_empty() { + println!("(no active flows)"); + } + for it in items { + println!("{}", it.pipeline); + for s in it.sockets { + println!(" {}", s.display()); + } + } + } + other => print_unexpected(&other), + } + } + + Cmd::Flow(FlowCmd::Throughput) => { + let resp = round_trip(&mut stream, Request::FlowThroughput).await?; + match resp { + Response::FlowThroughput { items } => { + if items.is_empty() { + println!("(no active flows)"); + } + for it in items { + let name = it.socket.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| it.socket.display().to_string()); + let kib = it.bytes_total as f64 / 1024.0; + let kbs = it.bytes_per_sec / 1024.0; + println!("{:<60} {:>8.1} KiB total {:>8.2} KiB/s", name, kib, kbs); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Flow(FlowCmd::Drop { pipeline }) => { + let pid = Ulid::from_string(&pipeline).map_err(|e| anyhow!("invalid pipeline id: {e}"))?; + let resp = round_trip(&mut stream, Request::FlowDrop { pipeline: pid }).await?; + match resp { + Response::FlowDropped { pipeline, existed } => { + if existed { + println!("dropped {pipeline}"); + } else { + eprintln!("no existía: {pipeline}"); + } + } + other => print_unexpected(&other), + } + } + + Cmd::Flow(FlowCmd::Tail { socket }) => { + // Subscribirse directo al socket — no pasamos por el daemon. + use tokio::io::AsyncReadExt; + let mut s = UnixStream::connect(&socket) + .await + .with_context(|| format!("connect {}", socket.display()))?; + let mut buf = [0u8; 4096]; + loop { + let n = s.read(&mut buf).await?; + if n == 0 { + break; + } + use std::io::Write; + let _ = std::io::stdout().write_all(&buf[..n]); + let _ = std::io::stdout().flush(); + } + } + + Cmd::Discern { path } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + // Sample: hasta 4 KiB. + let sample = bytes.into_iter().take(4096).collect(); + let resp = round_trip( + &mut stream, + Request::Discern { + sample, + hint_path: Some(path), + }, + ) + .await?; + match resp { + Response::Discernment { ty, confidence, mime, lens } => { + println!("type: {ty}"); + println!("confidence: {confidence:.2}"); + if let Some(m) = mime { + println!("mime: {m}"); + } + if let Some(l) = lens { + println!("lens: {l}"); + } + } + Response::Error { message } => return Err(anyhow!(message)), + other => print_unexpected(&other), + } + } + } + + Ok(()) +} + +async fn round_trip(stream: &mut UnixStream, req: Request) -> Result { + write_frame(stream, &req).await?; + let resp: Response = read_frame(stream).await?; + Ok(resp) +} + +fn parse_ws_id(s: &str) -> Result { + let u = Ulid::from_string(s).map_err(|e| anyhow!("invalid workspace id: {e}"))?; + Ok(WorkspaceId(u)) +} + +fn print_unexpected(r: &Response) { + eprintln!("unexpected response: {r:?}"); +} + +/// Imprime el resultado del launch del pipeline y retorna el path del +/// primer flow socket (si hay), útil para `--tail`. +fn print_pipeline_started_returning_socket(resp: Response) -> Result> { + match resp { + Response::PipelineStarted { pipeline, command_pids, edges } => { + println!("pipeline {pipeline}"); + for (label, pid) in command_pids { + println!(" {:<20} pid={pid}", label); + } + let mut first_socket: Option = None; + if !edges.is_empty() { + println!("edges:"); + for e in &edges { + println!( + " {}.{} → {}.{} ty={:?} mime={:?} conf={:.2}", + e.from_label, e.from_output, e.to_label, e.to_input, + e.ty, e.mime, e.confidence, + ); + if first_socket.is_none() { + first_socket = e.flow_socket.clone(); + } + } + } + Ok(first_socket) + } + Response::Error { message } => Err(anyhow!(message)), + other => { + print_unexpected(&other); + Ok(None) + } + } +} + +async fn tail_socket(socket: &std::path::Path) -> Result<()> { + use tokio::io::AsyncReadExt; + // Pequeña ventana de retry — el daemon retiene el flow channel + // antes de retornar, así que en la práctica ya está bindeado. + let mut s = UnixStream::connect(socket) + .await + .with_context(|| format!("connect {}", socket.display()))?; + let mut buf = [0u8; 4096]; + loop { + let n = s.read(&mut buf).await?; + if n == 0 { + break; + } + use std::io::Write; + let _ = std::io::stdout().write_all(&buf[..n]); + let _ = std::io::stdout().flush(); + } + Ok(()) +} diff --git a/02_ruway/shuma/shuma-daemon/Cargo.toml b/02_ruway/shuma/shuma-daemon/Cargo.toml new file mode 100644 index 0000000..8c6246d --- /dev/null +++ b/02_ruway/shuma/shuma-daemon/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "shuma-daemon" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Daemon de shipote: dueño de los Workspaces, expone admin socket para shipote-cli." + +[[bin]] +name = "shuma-daemon" +path = "src/main.rs" + +[dependencies] +shuma-card = { path = "../sandbox/shuma-card" } +shuma-protocol = { path = "../sandbox/shuma-protocol" } +shuma-discern = { workspace = true } +shuma-core = { path = "../sandbox/shuma-core" } +shuma-exec = { path = "../sandbox/shuma-exec" } +shuma-link = { path = "../sandbox/shuma-link" } +arje-incarnate = { workspace = true } +card-core = { workspace = true } +card-sidecar = { workspace = true } +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +ulid = { workspace = true } +nix = { workspace = true } +libc = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/02_ruway/shuma/shuma-daemon/LEEME.md b/02_ruway/shuma/shuma-daemon/LEEME.md new file mode 100644 index 0000000..835523d --- /dev/null +++ b/02_ruway/shuma/shuma-daemon/LEEME.md @@ -0,0 +1,15 @@ +# shuma-daemon + +> Daemon de sesiones de [shuma](../README.md). + +Mantiene sesiones vivas en background; clientes (CLI, Llimphi, remoto) se conectan/desconectan sin perder estado. Reemplaza `tmux`/`screen` en este monorepo. + +## Uso + +```sh +cargo run --release -p shuma-daemon -- --listen unix:/tmp/shuma.sock +``` + +## Deps + +- [`shuma-core`](../shuma-core/README.md), [`shuma-session`](../shuma-session/README.md), [`shuma-protocol`](../shuma-protocol/README.md) diff --git a/02_ruway/shuma/shuma-daemon/README.md b/02_ruway/shuma/shuma-daemon/README.md new file mode 100644 index 0000000..77ca390 --- /dev/null +++ b/02_ruway/shuma/shuma-daemon/README.md @@ -0,0 +1,15 @@ +# shuma-daemon + +> Session daemon of [shuma](../README.md). + +Keeps sessions alive in background; clients (CLI, Llimphi, remote) connect/disconnect without losing state. Replaces `tmux`/`screen` in this monorepo. + +## Usage + +```sh +cargo run --release -p shuma-daemon -- --listen unix:/tmp/shuma.sock +``` + +## Deps + +- [`shuma-core`](../sandbox/shuma-core/README.md), [`shuma-session`](../sandbox/shuma-session/README.md), [`shuma-protocol`](../sandbox/shuma-protocol/README.md) diff --git a/02_ruway/shuma/shuma-daemon/src/main.rs b/02_ruway/shuma/shuma-daemon/src/main.rs new file mode 100644 index 0000000..ff4f66d --- /dev/null +++ b/02_ruway/shuma/shuma-daemon/src/main.rs @@ -0,0 +1,1723 @@ +//! `shuma-daemon` — punto de entrada del runtime de shuma. +//! +//! Responsabilidades: +//! - Escuchar el Unix socket admin (default: `$XDG_RUNTIME_DIR/shuma.sock`). +//! - Despachar mensajes del [`shuma_protocol`] al [`WorkspaceManager`]. +//! - Reapear hijos periódicamente. +//! +//! Lo que NO hace en v1: +//! - Sidecar al broker / handshake con Init (futuro: cuando un workspace +//! exponga `service_socket`, anunciar al broker). +//! - GUI (futuro `shuma-shell` con nahual_launcher). + +use anyhow::Context; +use card_core::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Supervision, TypeRef}; +use arje_incarnate::IncarnatorConfig; +use shuma_core::WorkspaceManager; +use shuma_discern::{DiscernPipeline, Hint}; +use shuma_link::{FramedChannel, KnownPeers}; +use shuma_protocol::{ + default_socket_path, read_frame, write_frame, CommandInfo as ProtoCommandInfo, + EdgeDiscernmentInfo, ExecKind as ProtoExecKind, FlowInfo, FlowThroughputInfo, QuotaReportInfo, + Request, Response, WorkspaceStatsInfo, WorkspaceSummary, +}; +use std::sync::Arc; +use tokio::net::{UnixListener, UnixStream}; +use tracing::{error, info, warn}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + let sock = default_socket_path(); + let pid_path = pid_path_for(&sock); + + // 1) Lock exclusivo no-bloqueante sobre el `.pid` — singleton garantizado + // al nivel del kernel. Si falla, OTRO shuma-daemon vivo lo tiene y + // abortamos con un mensaje claro (incluye el PID dueño). El handle + // se retiene hasta el final de `main`: cuando se drop, el OS libera + // el lock automáticamente (incluso si crasheamos sin Drop ordenado). + let _lockfile = acquire_lockfile(&pid_path).with_context(|| { + format!( + "no se pudo adquirir el lockfile {} — ¿hay otro shuma-daemon corriendo?", + pid_path.display() + ) + })?; + + // 2) Defensa adicional contra socket vivo (el lockfile protege contra + // dos instancias del mismo usuario; este chequeo cubre el caso de + // socket dejado por otro usuario o por un proceso que escapó del + // lock por compartir directorio). + if socket_in_use(&sock) { + anyhow::bail!( + "el socket {} ya está atendido por otro proceso — abortando para no pisarlo", + sock.display() + ); + } + + if sock.exists() { + // Socket stale (post-crash): el lockfile ya garantizó que no hay + // otro daemon vivo, así que es seguro barrerlo. + let _ = std::fs::remove_file(&sock); + } + if let Some(parent) = sock.parent() { + let _ = std::fs::create_dir_all(parent); + } + let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?; + info!(socket = %sock.display(), pid_lock = %pid_path.display(), "shuma-daemon listening"); + let daemon_started = std::time::Instant::now(); + + // Sidecar pool: una sesión global del daemon + N sesiones efímeras + // por edge enriquecido tras cada pipeline tap. + let sidecar_pool = match card_sidecar::SidecarPool::new() { + Ok(p) => Some(Arc::new(p)), + Err(e) => { + warn!(?e, "SidecarPool falló — broker integration disabled"); + None + } + }; + if let Some(pool) = &sidecar_pool { + pool.spawn(build_daemon_card(&sock)); + } + + let mgr = Arc::new(WorkspaceManager::new(IncarnatorConfig { + // El daemon aún no se conecta al broker; cuando lo haga, este path + // se llenará desde el handshake. + bus_sock: None, + notify_socket: None, + extra_env: vec![("SHIPOTE_DAEMON".into(), "1".into())], + // strict_caps=false en v1: queremos UX permisiva (correr en non-root + // sin user_ns y avisar via warnings, no abortar). + strict_caps: false, + })); + + // Restaurar snapshot previo si existe. Workspaces se recrean; los + // pids de comandos viejos NO se recuperan (kernel los mató). Los + // pipelines vivos (con supervisor) se relanzan desde cero. + let snapshot_path = shuma_core::persist::default_snapshot_path(); + let restore = match mgr.restore_snapshot(&snapshot_path).await { + Ok(r) => r, + Err(e) => { + warn!(?e, "restore_snapshot falló — start fresh"); + shuma_core::persist::RestoreOutcome::default() + } + }; + // Relauncher de live_pipelines: como necesita inc+disc del daemon, + // lo hacemos acá tras el restore. Cada uno mismo flujo que un run + // normal — register_pipeline_commands + register_pipeline_supervisor. + for entry in restore.live_pipelines { + let inc = mgr.incarnator_handle(); + let disc = Arc::new(DiscernPipeline::default_pipeline()); + let workspace = entry.workspace; + let ws_label = mgr.workspace_label(workspace).await.unwrap_or_default(); + let tap = entry.tap; + let spec = entry.spec; + match shuma_core::pipeline::run_pipeline( + &spec, &ws_label, tap, disc, inc, Some(mgr.clone()), + ) + .await + { + Ok(launch) => { + mgr.register_pipeline_commands(workspace, launch.pipeline, launch.command_pids.clone()).await; + mgr.register_pipeline_supervisor(launch.pipeline, workspace, spec, tap).await; + info!(label = %launch.pipeline, "live pipeline relaunched from snapshot"); + } + Err(e) => warn!(?e, "live pipeline relaunch failed"), + } + } + + // Shutdown handler: SIGTERM/SIGINT → drain (stop_with_grace de todos + // los workspaces) → snapshot → exit. El drain usa grace=1s para dar + // chance a los comandos a terminar limpio antes del SIGKILL. + { + let mgr = mgr.clone(); + let path = snapshot_path.clone(); + tokio::spawn(async move { + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("SIGTERM handler"); + let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .expect("SIGINT handler"); + let sig_name = tokio::select! { + _ = term.recv() => "SIGTERM", + _ = int.recv() => "SIGINT", + }; + info!(signal = sig_name, "shuma-daemon shutdown: draining workspaces"); + + // 1) Snapshot ANTES del drain — preserva intención declarada + // (los workspace specs siguen vivos en el snapshot aunque + // matemos los procesos hijos). + if let Err(e) = mgr.save_snapshot(&path).await { + warn!(?e, "save_snapshot falló"); + } + + // 2) Drain: stop_with_grace de todos los workspaces vivos. + // Grace 1s da chance a apps Type=notify de hacer cleanup. + let workspaces = mgr.list().await; + let n = workspaces.len(); + for ws in workspaces { + if let Err(e) = mgr + .stop_with_grace(ws.id, std::time::Duration::from_millis(1000)) + .await + { + warn!(?e, %ws.id, "stop_with_grace falló en drain"); + } + } + info!(drained = n, "drain complete"); + std::process::exit(0); + }); + } + + let discerner = Arc::new(DiscernPipeline::default_pipeline()); + + // Reaper periódico cada 500 ms. Además drena pipelines pendientes + // de restart (supervisión a nivel pipeline). + { + let mgr = mgr.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_millis(500)); + loop { + tick.tick().await; + mgr.reap_dead().await; + let pending = mgr.take_pending_restarts().await; + for sup in pending { + let backoff = std::time::Duration::from_millis(sup.current_backoff_ms); + info!( + label = %sup.spec.label, + restart_count = sup.restart_count, + backoff_ms = sup.current_backoff_ms, + "pipeline restart: relaunching after backoff" + ); + // Backoff antes del relaunch — anti-thrash. + tokio::time::sleep(backoff).await; + let inc = mgr.incarnator_handle(); + let disc = std::sync::Arc::new(DiscernPipeline::default_pipeline()); + let workspace = sup.spec.workspace; + let ws_label = mgr.workspace_label(workspace).await.unwrap_or_default(); + let tap = sup.tap; + let mut new_spec = sup.spec.clone(); + new_spec.restart_on_failure = true; + // Escalar el backoff para la PRÓXIMA falla. + let next_backoff = (sup.current_backoff_ms * 2) + .min(new_spec.restart_max_backoff_ms); + match shuma_core::pipeline::run_pipeline( + &new_spec, + &ws_label, + tap, + disc, + inc, + Some(mgr.clone()), + ) + .await + { + Ok(launch) => { + mgr.register_pipeline_commands( + workspace, + launch.pipeline, + launch.command_pids.clone(), + ) + .await; + // Re-registrar supervisor con backoff escalado + + // restart_count preservado. + mgr.register_pipeline_supervisor_with_state( + launch.pipeline, + workspace, + new_spec, + tap, + sup.restart_count, + next_backoff, + ) + .await; + } + Err(e) => { + warn!(?e, "pipeline restart failed"); + } + } + } + } + }); + } + + // UID propio (para auth). SHIPOTE_TRUST_ANYONE=1 deshabilita. + let own_uid = nix::unistd::getuid().as_raw(); + let trust_anyone = std::env::var("SHIPOTE_TRUST_ANYONE").as_deref() == Ok("1"); + if trust_anyone { + warn!("SHIPOTE_TRUST_ANYONE=1 — accepting any peer uid"); + } + + // Listener TCP autenticado opt-in: `SHUMA_LISTEN_TCP=host:port` lo + // activa. Cada conexión hace handshake Noise XK con la identidad + // del daemon (auto-generada y persistida en `~/.config/shuma/keys/ + // identity.x25519`) y valida la pubkey del cliente contra la + // allowlist `~/.config/shuma/known_peers.txt`. Es la base para que + // shuma-remote-exec hable con un daemon remoto sin SSH externo. + if let Ok(addr) = std::env::var("SHUMA_LISTEN_TCP") { + let kp_path = shuma_link::Keypair::default_path() + .context("no se puede determinar el directorio de configuración (XDG)")?; + let keypair = shuma_link::Keypair::load_or_generate(&kp_path) + .context("identity keypair")?; + let peers_path = KnownPeers::default_path() + .context("no se puede determinar el directorio de configuración (XDG)")?; + let tcp_listener = tokio::net::TcpListener::bind(&addr) + .await + .with_context(|| format!("bind TCP {addr}"))?; + info!( + socket = %addr, + identity = %keypair.public().to_hex(), + "shuma-daemon TCP listener up (Noise XK + KnownPeers)", + ); + let mgr_tcp = mgr.clone(); + let disc_tcp = discerner.clone(); + let pool_tcp = sidecar_pool.clone(); + let daemon_started_tcp = daemon_started; + tokio::spawn(async move { + loop { + match tcp_listener.accept().await { + Ok((tcp, remote_addr)) => { + // Re-cargamos peers en cada accept — barato, y + // permite editar el allowlist sin reiniciar. + let peers = KnownPeers::load(&peers_path).unwrap_or_default(); + let our_kp = keypair.clone(); + let mgr = mgr_tcp.clone(); + let disc = disc_tcp.clone(); + let pool = pool_tcp.clone(); + tokio::spawn(async move { + if let Err(e) = handle_enc_client( + tcp, + our_kp, + peers, + mgr, + disc, + pool, + daemon_started_tcp, + ) + .await + { + warn!(?e, %remote_addr, "encrypted client handler error"); + } + }); + } + Err(e) => { + error!(?e, "TCP accept failed"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } + }); + } + + loop { + match listener.accept().await { + Ok((stream, _)) => { + // Auth: SO_PEERCRED es automático en Unix sockets. Si + // el uid del peer no coincide con el nuestro, rechazo + // antes de procesar nada (a menos que esté permitido). + if !trust_anyone { + match peer_uid(&stream) { + Ok(peer) if peer == own_uid => {} + Ok(peer) => { + warn!(peer, own = own_uid, "rejecting peer with different uid"); + drop(stream); + continue; + } + Err(e) => { + warn!(?e, "could not read peer uid — rejecting"); + drop(stream); + continue; + } + } + } + let mgr = mgr.clone(); + let disc = discerner.clone(); + let pool = sidecar_pool.clone(); + tokio::spawn(async move { + if let Err(e) = handle_client(stream, mgr, disc, pool, daemon_started).await { + warn!(?e, "client handler error"); + } + }); + } + Err(e) => { + error!(?e, "accept failed"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } +} + +/// Lee SO_PEERCRED del Unix socket conectado. Devuelve el uid del peer. +fn peer_uid(stream: &tokio::net::UnixStream) -> std::io::Result { + use std::os::fd::AsRawFd; + let fd = stream.as_raw_fd(); + let mut ucred: libc::ucred = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + let r = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut ucred as *mut _ as *mut _, + &mut len, + ) + }; + if r != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(ucred.uid) +} + +async fn handle_client( + mut stream: UnixStream, + mgr: Arc, + disc: Arc, + pool: Option>, + daemon_started: std::time::Instant, +) -> anyhow::Result<()> { + // Audit: peer uid lo leemos una vez aquí (no cambia durante la conexión). + let peer_id = match peer_uid(&stream) { + Ok(u) => format!("uid:{u}"), + Err(_) => "uid:unknown".to_string(), + }; + loop { + let req: Request = match read_frame(&mut stream).await { + Ok(r) => r, + Err(shuma_protocol::ProtocolError::Closed) => return Ok(()), + Err(e) => return Err(e.into()), + }; + audit_request(&peer_id, &req); + + // El subprotocolo `ExecStream` produce N frames sobre la misma + // conexión hasta un terminal. Lo manejamos inline en vez de pasar + // por `dispatch` (que es request/response 1:1). + if let Request::ExecStream { cwd, exec, capture_limit_bytes, stdin_data, capture_stages } = req { + handle_exec_stream(&mut stream, cwd, exec, capture_limit_bytes, stdin_data, capture_stages).await?; + continue; + } + // PTY remoto: la conexión pasa a modo full-duplex y se consume + // hasta el exit (cierra al terminar, como el path cifrado). + if let Request::ExecPty { cwd, program, args, rows, cols } = req { + return handle_pty_stream(stream, cwd, program, args, rows, cols).await; + } + + let resp = dispatch(&mgr, &disc, &pool, daemon_started, req).await; + write_frame(&mut stream, &resp).await?; + } +} + +/// Subprotocolo ExecStream: spawnea con `shuma-exec` y reemite cada +/// evento como un frame de `Response::Exec*` sobre `stream`. Cuando el +/// cliente cierra la conexión a mitad de stream, detectamos el error de +/// escritura y matamos el proceso — convención SSH/PTY. +async fn handle_exec_stream( + stream: &mut UnixStream, + cwd: String, + exec: ProtoExecKind, + capture_limit_bytes: usize, + stdin_data: Option, + capture_stages: bool, +) -> anyhow::Result<()> { + let exec = match exec { + ProtoExecKind::Shell { line, program } => shuma_exec::Exec::Shell { line, program }, + ProtoExecKind::Direct { stages } => shuma_exec::Exec::Direct { + stages: stages + .into_iter() + .map(|s| shuma_exec::StageSpec { program: s.program, args: s.args }) + .collect(), + }, + }; + let spec = shuma_exec::CommandSpec { + exec, + cwd, + capture_limit: capture_limit_bytes, + spill_path: None, // el cliente no expone path local del daemon + stdin_data, + capture_stages, + }; + let mut handle = shuma_exec::run(&spec); + // Capturamos el "Killer" antes de mover el RunHandle al hilo bridge — + // así podemos disparar SIGKILL desde la tarea async sin contender el + // lock del lector (`next_event` puede estar bloqueado en `rx.recv`). + let killer = handle.killer(); + + // Bridge sync→async: un hilo dedicado bloquea en `next_event()` y + // reenvía por un canal tokio. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let _bridge = std::thread::spawn(move || { + while let Some(ev) = handle.next_event() { + if tx.send(ev).is_err() { + return; + } + } + }); + + // Loop dual: por un lado los eventos del proceso (rx), por otro el + // socket del cliente. Cualquier byte o EOF en el socket significa + // "abort" (el cliente no debe escribir mientras dura el stream). + // Sin este watcher, un proceso silencioso (p.ej. `sleep 30`) deja + // colgado al handler hasta que el cliente cae por timeout TCP. + let mut byte = [0u8; 1]; + loop { + tokio::select! { + biased; + ev = rx.recv() => { + let Some(ev) = ev else { break }; + let terminal = matches!( + ev, + shuma_exec::RunEvent::Exited(_) | shuma_exec::RunEvent::Failed(_) + ); + let resp = exec_event_to_response(ev); + if let Err(e) = write_frame(stream, &resp).await { + killer.kill(); + return Err(e.into()); + } + if terminal { + break; + } + } + // `read_exact` es cancel-safe en tokio: si se cancela la rama + // no se pierden bytes. Aquí cualquier resultado (Ok = byte + // inesperado, Err = EOF/error) cuenta como señal de abort. + res = tokio::io::AsyncReadExt::read_exact(stream, &mut byte) => { + let _ = res; // ambos sentidos significan "kill" + killer.kill(); + // Drenamos lo que quede en cola hasta el terminal para + // que el bridge cierre limpio (el thread del lector saldrá + // solo cuando el proceso muera y los readers vean EOF). + while let Some(ev) = rx.recv().await { + if matches!(ev, shuma_exec::RunEvent::Exited(_) | shuma_exec::RunEvent::Failed(_)) { + break; + } + } + break; + } + } + } + Ok(()) +} + +fn exec_event_to_response(ev: shuma_exec::RunEvent) -> Response { + match ev { + shuma_exec::RunEvent::Stdout(l) => Response::ExecStdout(l), + // Salida de una etapa intermedia del pipe (tee). Sólo aparece si + // el cliente pidió `capture_stages: true`; la reemitimos como su + // propio frame para que el cliente la pinte en el desplegable de + // la etapa correspondiente, no mezclada con el stdout final. + shuma_exec::RunEvent::StageStdout { stage, line } => { + Response::ExecStageStdout { stage, line } + } + shuma_exec::RunEvent::Stderr(l) => Response::ExecStderr(l), + shuma_exec::RunEvent::Truncated => Response::ExecTruncated, + shuma_exec::RunEvent::Spilled(p) => Response::ExecSpilled(p), + shuma_exec::RunEvent::Exited(c) => Response::ExecExited(c), + shuma_exec::RunEvent::Failed(m) => Response::ExecFailed(m), + // PTY bytes no se reemiten por ExecStream (el protocolo es + // unidireccional). El subprotocolo nunca debería verlos porque + // la request `ExecStream` traduce a `Exec::Direct/Shell`, no + // `Exec::Pty`. Si llegan, los reportamos como un fallo para no + // perderlos silenciosamente. + shuma_exec::RunEvent::Bytes(_) => Response::ExecFailed( + "PTY bytes inesperados en ExecStream — el daemon no debió spawnear PTY aquí".into(), + ), + } +} + +/// Traductor del lado PTY: lo que produce `Exec::Pty` son `Bytes` crudos +/// (la salida del terminal); el resto de variantes no debería aparecer. +fn pty_event_to_response(ev: shuma_exec::RunEvent) -> Response { + match ev { + shuma_exec::RunEvent::Bytes(b) => Response::ExecBytes(b), + shuma_exec::RunEvent::Exited(c) => Response::ExecExited(c), + shuma_exec::RunEvent::Failed(m) => Response::ExecFailed(m), + // Un PTY captura a su propia pantalla (vt100), no a buffers de + // línea; estas variantes no deberían darse. Si pasan, las + // reemitimos como bytes para no perderlas. + shuma_exec::RunEvent::Stdout(l) | shuma_exec::RunEvent::Stderr(l) => { + Response::ExecBytes(l.into_bytes()) + } + shuma_exec::RunEvent::StageStdout { line, .. } => Response::ExecBytes(line.into_bytes()), + shuma_exec::RunEvent::Truncated | shuma_exec::RunEvent::Spilled(_) => { + Response::ExecBytes(Vec::new()) + } + } +} + +/// Subprotocolo `ExecPty` (texto plano): spawnea un PTY y multiplexa la +/// conexión en full-duplex. Una **tarea lectora** dedicada decodifica los +/// frames del cliente (`PtyInput`/`PtyResize`) y maneja el PTY, mientras +/// el loop principal escribe la salida del terminal (`ExecBytes`). Separar +/// lectura y escritura en mitades owned evita cancelar un `read_frame` a +/// mitad de frame (cancel-safety) sin malabares de borrow. +async fn handle_pty_stream( + stream: UnixStream, + cwd: String, + program: String, + args: Vec, + rows: u16, + cols: u16, +) -> anyhow::Result<()> { + let spec = pty_spec(cwd, program, args, rows, cols); + let mut handle = shuma_exec::run(&spec); + let killer = handle.killer(); + let pty = handle.pty_control(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let _bridge = std::thread::spawn(move || { + while let Some(ev) = handle.next_event() { + if tx.send(ev).is_err() { + return; + } + } + }); + + let (mut rd, mut wr) = tokio::io::split(stream); + // Tarea lectora: drena frames del cliente y maneja el PTY directo. + let reader = tokio::spawn(async move { + loop { + match read_frame::(&mut rd).await { + Ok(Request::PtyInput { bytes }) => { + pty.write_input(bytes); + } + Ok(Request::PtyResize { rows, cols }) => { + pty.resize(rows, cols); + } + Ok(_) => {} // frame fuera del protocolo PTY: ignorar + Err(_) => { + // EOF/error = cliente cerró → matar el PTY (SSH). + killer.kill(); + break; + } + } + } + }); + + // Loop de escritura: salida del terminal hasta el terminal. + while let Some(ev) = rx.recv().await { + let terminal = matches!( + ev, + shuma_exec::RunEvent::Exited(_) | shuma_exec::RunEvent::Failed(_) + ); + let resp = pty_event_to_response(ev); + if write_frame(&mut wr, &resp).await.is_err() { + break; + } + if terminal { + break; + } + } + reader.abort(); + Ok(()) +} + +/// Versión cifrada de [`handle_pty_stream`]. Consume el `FramedChannel` +/// (el `split` es owned) y usa sus mitades `FramedReader`/`FramedWriter`. +async fn handle_pty_stream_enc( + ch: FramedChannel, + cwd: String, + program: String, + args: Vec, + rows: u16, + cols: u16, +) -> anyhow::Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let spec = pty_spec(cwd, program, args, rows, cols); + let mut handle = shuma_exec::run(&spec); + let killer = handle.killer(); + let pty = handle.pty_control(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let _bridge = std::thread::spawn(move || { + while let Some(ev) = handle.next_event() { + if tx.send(ev).is_err() { + return; + } + } + }); + + let (mut rd, mut wr) = ch.split(); + let reader = tokio::spawn(async move { + loop { + match rd.recv_postcard::().await { + Ok(Request::PtyInput { bytes }) => { + pty.write_input(bytes); + } + Ok(Request::PtyResize { rows, cols }) => { + pty.resize(rows, cols); + } + Ok(_) => {} + Err(_) => { + killer.kill(); + break; + } + } + } + }); + + while let Some(ev) = rx.recv().await { + let terminal = matches!( + ev, + shuma_exec::RunEvent::Exited(_) | shuma_exec::RunEvent::Failed(_) + ); + let resp = pty_event_to_response(ev); + if wr.send_postcard(&resp).await.is_err() { + break; + } + if terminal { + break; + } + } + reader.abort(); + Ok(()) +} + +/// `CommandSpec` para un PTY remoto — común a los dos handlers. +fn pty_spec( + cwd: String, + program: String, + args: Vec, + rows: u16, + cols: u16, +) -> shuma_exec::CommandSpec { + shuma_exec::CommandSpec { + exec: shuma_exec::Exec::Pty { program, args, cols, rows }, + cwd, + capture_limit: 0, + spill_path: None, + stdin_data: None, + capture_stages: false, + } +} + +/// Atiende una conexión TCP autenticada por Noise XK. +/// +/// Flujo: +/// 1. Handshake server: descubre la pubkey del cliente. +/// 2. Verifica que esté en `peers` (allowlist). Si no, log + drop. +/// 3. Loop dispatch idéntico a `handle_client` pero sobre +/// `FramedChannel` en vez de UnixStream. Para `ExecStream`, usa +/// `handle_exec_stream_enc`. +/// +/// **Limitación v1**: a diferencia del path Unix, mid-stream cancel +/// del cliente sólo se detecta cuando el daemon intenta escribir el +/// próximo evento. Para procesos silenciosos (p. ej. `sleep 30` sin +/// salida), un cliente que cierra TCP no dispara el kill hasta que el +/// proceso emita algo. Se mejora cuando se añada un frame Cancel del +/// cliente o se splittea el FramedChannel en mitades sender/receiver. +async fn handle_enc_client( + tcp: tokio::net::TcpStream, + our_keypair: shuma_link::Keypair, + peers: KnownPeers, + mgr: Arc, + disc: Arc, + pool: Option>, + daemon_started: std::time::Instant, +) -> anyhow::Result<()> { + let (mut ch, peer) = shuma_link::server_handshake(tcp, &our_keypair) + .await + .map_err(|e| anyhow::anyhow!("handshake: {e}"))?; + if !peers.contains(&peer) { + warn!(peer = %peer.to_hex(), "TCP peer no autorizado — rechazando"); + // Cerramos sin enviar nada: no le damos al atacante señal de + // si la pubkey existió alguna vez (timing-uniform reject). + return Ok(()); + } + info!(peer = %peer.to_hex(), "TCP peer autorizado, sirviendo"); + // Identidad del peer en el audit log: primeros 16 hex de su pubkey + // X25519 — suficientes para correlacionar entradas sin volcar la + // clave entera en cada línea. + let peer_hex = peer.to_hex(); + let peer_id = format!("pubkey:{}", &peer_hex[..peer_hex.len().min(16)]); + + loop { + let req: Request = match ch.recv_postcard().await { + Ok(r) => r, + Err(shuma_link::channel::FrameError::Closed) => return Ok(()), + Err(e) => return Err(anyhow::anyhow!("recv: {e}")), + }; + audit_request(&peer_id, &req); + + if let Request::ExecStream { cwd, exec, capture_limit_bytes, stdin_data, capture_stages } = req { + handle_exec_stream_enc(&mut ch, cwd, exec, capture_limit_bytes, stdin_data, capture_stages).await?; + continue; + } + // PTY remoto cifrado: consume el canal (el split es owned) y cierra + // la conexión al terminar — el cliente abre una conexión dedicada + // por sesión PTY, igual que con los runs no-PTY. + if let Request::ExecPty { cwd, program, args, rows, cols } = req { + return handle_pty_stream_enc(ch, cwd, program, args, rows, cols).await; + } + + let resp = dispatch(&mgr, &disc, &pool, daemon_started, req).await; + if let Err(e) = ch.send_postcard(&resp).await { + return Err(anyhow::anyhow!("send: {e}")); + } + } +} + +/// Versión encriptada de [`handle_exec_stream`]. Misma forma: traduce +/// la request al spec, spawnea con `shuma-exec`, puentea sync→async y +/// emite frames de `Response::Exec*` hasta el terminal. La diferencia +/// es que usa el `FramedChannel` en vez de `write_frame` directo, así +/// que cada postcard viaja cifrado y autenticado. +async fn handle_exec_stream_enc( + ch: &mut FramedChannel, + cwd: String, + exec: ProtoExecKind, + capture_limit_bytes: usize, + stdin_data: Option, + capture_stages: bool, +) -> anyhow::Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, +{ + let exec_local = match exec { + ProtoExecKind::Shell { line, program } => shuma_exec::Exec::Shell { line, program }, + ProtoExecKind::Direct { stages } => shuma_exec::Exec::Direct { + stages: stages + .into_iter() + .map(|s| shuma_exec::StageSpec { program: s.program, args: s.args }) + .collect(), + }, + }; + let spec = shuma_exec::CommandSpec { + exec: exec_local, + cwd, + capture_limit: capture_limit_bytes, + spill_path: None, + stdin_data, + capture_stages, + }; + let mut handle = shuma_exec::run(&spec); + let killer = handle.killer(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let _bridge = std::thread::spawn(move || { + while let Some(ev) = handle.next_event() { + if tx.send(ev).is_err() { + return; + } + } + }); + while let Some(ev) = rx.recv().await { + let terminal = matches!( + ev, + shuma_exec::RunEvent::Exited(_) | shuma_exec::RunEvent::Failed(_) + ); + let resp = exec_event_to_response(ev); + if let Err(e) = ch.send_postcard(&resp).await { + // Cliente cerró: matamos al hijo. No drenamos rx — el + // bridge thread saldrá solo cuando los readers vean EOF. + killer.kill(); + return Err(anyhow::anyhow!("send: {e}")); + } + if terminal { + break; + } + } + Ok(()) +} + +/// Path canónico del audit log: `$XDG_STATE_HOME/shuma/audit.log` o +/// fallback `$HOME/.local/state/shuma/audit.log`. +fn default_audit_log_path() -> std::path::PathBuf { + if let Ok(state) = std::env::var("XDG_STATE_HOME") { + return std::path::PathBuf::from(state).join("shuma/audit.log"); + } + if let Ok(home) = std::env::var("HOME") { + return std::path::PathBuf::from(home).join(".local/state/shuma/audit.log"); + } + std::path::PathBuf::from("/tmp/shuma-audit.log") +} + +/// Cap del audit log antes de rotar a `audit.log.1`. 1 MiB. +const AUDIT_LOG_MAX_BYTES: u64 = 1 << 20; + +/// Append + rotate (mueve a `.1` si supera el cap). Append-only, sin +/// reordenar. Sync: cada line, fsync no — el log es defensive, no +/// transactional. +fn append_audit_line(path: &std::path::Path, line: &str) -> std::io::Result<()> { + use std::io::Write; + // Rotar si pasa el cap. + if let Ok(meta) = std::fs::metadata(path) { + if meta.len() >= AUDIT_LOG_MAX_BYTES { + let rotated = path.with_extension("log.1"); + let _ = std::fs::rename(path, &rotated); + } + } + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + writeln!(f, "{line}")?; + Ok(()) +} + +/// Loguea cada mutación con target="audit" y el peer. Reads (ping, +/// list, stats) se omiten para no inundar el log. `peer` es opaco: +/// para Unix sockets viene como `"uid:1000"` (de `SO_PEERCRED`); para +/// TCP autenticado viene como `"pubkey:abcdef…"` (los 16 primeros hex +/// de la X25519 pública del peer). +fn audit_request(peer: &str, req: &Request) { + let (action, detail) = match req { + Request::WorkspaceCreate { spec } => ("workspace.create", format!("label={}", spec.label)), + Request::WorkspaceStop { id, grace_ms } => ("workspace.stop", format!("id={id} grace_ms={grace_ms}")), + Request::Run { workspace, exec, restart_on_failure, .. } => ( + "run", + format!("ws={workspace} exec={exec} restart={restart_on_failure}"), + ), + Request::PipelineRun { spec, tap, .. } => ("pipeline.run", format!("label={} tap={tap}", spec.label)), + Request::PipelineRunSaved { name, tap, .. } => ("pipeline.run-saved", format!("name={name} tap={tap}")), + Request::PipelineStop { pipeline, grace_ms } => ("pipeline.stop", format!("id={pipeline} grace_ms={grace_ms}")), + Request::PipelineSave { name, .. } => ("pipeline.save", format!("name={name}")), + Request::PipelineDrop { name } => ("pipeline.drop", format!("name={name}")), + Request::FlowDrop { pipeline } => ("flow.drop", format!("pipeline={pipeline}")), + Request::ExecStream { cwd, exec, .. } => { + let summary = match exec { + ProtoExecKind::Shell { line, .. } => format!("shell={line:?}"), + ProtoExecKind::Direct { stages } => stages + .iter() + .map(|s| s.program.as_str()) + .collect::>() + .join(" | "), + }; + ("exec.stream", format!("cwd={cwd} {summary}")) + } + Request::ExecPty { cwd, program, args, .. } => ( + "exec.pty", + format!("cwd={cwd} {program} {}", args.join(" ")), + ), + // Reads / alta frecuencia (no audit). Las teclas y resizes de un + // PTY no se auditan línea a línea — la apertura (`exec.pty`) ya + // quedó registrada. + Request::PtyInput { .. } + | Request::PtyResize { .. } + | Request::Ping + | Request::Health + | Request::WorkspaceList + | Request::WorkspaceStats { .. } + | Request::WorkspaceQuota { .. } + | Request::WorkspaceStatsHistory { .. } + | Request::WorkspaceFullSummary { .. } + | Request::CommandList { .. } + | Request::CommandLogs { .. } + | Request::PipelineSavedList + | Request::FlowList + | Request::FlowThroughput + | Request::Discern { .. } + | Request::Capabilities => return, + }; + info!(target: "audit", peer, action, detail = %detail, "audit"); + // Append a file. Failure no es fatal — sólo se pierde la entry. + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let line = format!("ts={ts} peer={peer} action={action} {detail}"); + let path = default_audit_log_path(); + if let Err(e) = append_audit_line(&path, &line) { + // Sólo loguear si el filesystem está roto. No reportar al cliente. + tracing::debug!(?e, path = %path.display(), "audit file write failed"); + } +} + +async fn dispatch( + mgr: &Arc, + disc: &DiscernPipeline, + pool: &Option>, + daemon_started: std::time::Instant, + req: Request, +) -> Response { + match req { + Request::Ping => Response::Pong, + + Request::Health => { + let counts = mgr.health_counts().await; + Response::Health { + version: env!("CARGO_PKG_VERSION").to_string(), + uptime_ms: daemon_started.elapsed().as_millis() as u64, + alive_workspaces: counts.alive_workspaces, + alive_commands: counts.alive_commands, + alive_pipelines: counts.alive_pipelines, + active_flows: counts.active_flows, + dirty: mgr.is_dirty(), + } + } + + Request::WorkspaceCreate { spec } => { + let label_clone = spec.label.clone(); + match mgr.create(spec).await { + Ok((id, warnings)) => { + if let Some(p) = pool.as_deref() { + p.spawn(build_workspace_card(&label_clone, id)); + } + Response::WorkspaceCreated { id, warnings } + } + Err(e) => Response::Error { message: format!("{e}") }, + } + } + + Request::WorkspaceList => { + let items = mgr + .list() + .await + .into_iter() + .map(|s| WorkspaceSummary { + id: s.id, + label: s.label, + commands: s.commands, + uptime_ms: s.uptime_ms, + }) + .collect(); + Response::WorkspaceList { items } + } + + Request::WorkspaceStop { id, grace_ms } => { + match mgr + .stop_with_grace(id, std::time::Duration::from_millis(grace_ms)) + .await + { + Ok(reaped) => Response::WorkspaceStopped { id, reaped }, + Err(e) => Response::Error { message: format!("{e}") }, + } + } + + Request::Run { workspace, exec, argv, envp, restart_on_failure } => { + match mgr + .run_with_options(workspace, exec, argv, envp, restart_on_failure) + .await + { + Ok(s) => Response::RunStarted { + workspace, + command_id: s.id, + pid: s.pid, + }, + Err(e) => Response::Error { message: format!("{e}") }, + } + } + + Request::PipelineRun { spec, tap, vars } => { + let vars_map: std::collections::HashMap = vars.into_iter().collect(); + let spec = match shuma_card::substitute_vars(&spec, &vars_map) { + Ok(s) => s, + Err(e) => return Response::Error { message: format!("template: {e}") }, + }; + let disc = DiscernPipeline::default_pipeline(); + let inc = mgr.incarnator_handle(); + let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default(); + match shuma_core::pipeline::run_pipeline( + &spec, + &ws_label, + tap, + std::sync::Arc::new(disc), + inc, + Some(mgr.clone()), + ) + .await + { + Ok(launch) => { + let pipeline_id = launch.pipeline; + announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments); + let cmds = launch.command_pids; + mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await; + mgr.register_pipeline_supervisor(pipeline_id, spec.workspace, spec.clone(), tap).await; + let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect(); + Response::PipelineStarted { + pipeline: pipeline_id, + command_pids: cmds, + edges, + } + } + Err(e) => Response::Error { message: format!("{e}") }, + } + } + + Request::Discern { sample, hint_path } => { + let path_str = hint_path.as_ref().and_then(|p| p.to_str()); + let hint = Hint { + path: path_str, + size_total: None, + }; + match disc.discern(&sample, &hint) { + Some(d) => Response::Discernment { + ty: format!("{:?}", d.ty), + confidence: d.confidence, + mime: d.mime, + lens: d.lens, + }, + None => Response::Error { message: "no discernment".into() }, + } + } + + Request::CommandList { workspace } => { + let items: Vec = mgr + .list_commands(workspace) + .await + .into_iter() + .map(|c| ProtoCommandInfo { + id: c.id, + label: c.label, + pid: c.pid, + alive: c.alive, + exit_status: c.exit_status, + log_bytes: c.log_bytes, + }) + .collect(); + Response::CommandList { items } + } + + Request::CommandLogs { workspace, command, tail_bytes, stream } => { + let s = match stream.as_str() { + "stdout" => shuma_core::LogStream::Stdout, + "stderr" => shuma_core::LogStream::Stderr, + _ => shuma_core::LogStream::Both, + }; + match mgr.get_command_logs(workspace, command, tail_bytes, s).await { + Some(bytes) => Response::CommandLogs { bytes }, + None => Response::Error { + message: format!("no logs for command {command} in workspace {workspace}"), + }, + } + } + + Request::PipelineSave { name, spec } => { + mgr.save_pipeline(name.clone(), spec).await; + Response::PipelineSaved { name } + } + + Request::PipelineSavedList => { + let names = mgr.list_saved_pipelines().await; + Response::PipelineSavedList { names } + } + + Request::PipelineDrop { name } => { + let existed = mgr.drop_saved_pipeline(&name).await; + Response::PipelineDropped { name, existed } + } + + Request::PipelineRunSaved { name, tap, vars } => match mgr.get_saved_pipeline(&name).await { + Some(spec) => { + let vars_map: std::collections::HashMap = vars.into_iter().collect(); + let spec = match shuma_card::substitute_vars(&spec, &vars_map) { + Ok(s) => s, + Err(e) => return Response::Error { message: format!("template: {e}") }, + }; + let disc = DiscernPipeline::default_pipeline(); + let inc = mgr.incarnator_handle(); + let ws_label = mgr.workspace_label(spec.workspace).await.unwrap_or_default(); + match shuma_core::pipeline::run_pipeline( + &spec, + &ws_label, + tap, + std::sync::Arc::new(disc), + inc, + Some(mgr.clone()), + ) + .await + { + Ok(launch) => { + let pipeline_id = launch.pipeline; + announce_edges_to_broker(pool.as_deref(), &pipeline_id, &launch.edge_discernments); + let cmds = launch.command_pids; + mgr.register_pipeline_commands(spec.workspace, pipeline_id, cmds.clone()).await; + mgr.register_pipeline_supervisor(pipeline_id, spec.workspace, spec.clone(), tap).await; + let edges = launch.edge_discernments.into_iter().map(map_edge_to_info).collect(); + Response::PipelineStarted { + pipeline: pipeline_id, + command_pids: cmds, + edges, + } + } + Err(e) => Response::Error { message: format!("{e}") }, + } + } + None => Response::Error { + message: format!("pipeline `{name}` no encontrado"), + }, + }, + + Request::PipelineStop { pipeline, grace_ms } => { + let reaped = mgr + .stop_pipeline(pipeline, std::time::Duration::from_millis(grace_ms)) + .await; + Response::PipelineStopped { pipeline, reaped } + } + + Request::WorkspaceStats { workspace } => match mgr.workspace_stats(workspace).await { + Some(s) => Response::WorkspaceStats { + info: WorkspaceStatsInfo { + 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, + source: s.source, + uptime_ms: s.uptime_ms, + }, + }, + None => Response::Error { + message: format!("workspace {workspace} not found"), + }, + }, + + Request::WorkspaceStatsHistory { workspace, tail } => { + match mgr.workspace_stats_history(workspace, tail).await { + Some(samples) => { + let mapped: Vec = samples + .into_iter() + .map(|s| WorkspaceStatsInfo { + 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, + source: s.source, + uptime_ms: s.uptime_ms, + }) + .collect(); + Response::WorkspaceStatsHistory { samples: mapped } + } + None => Response::Error { + message: format!("workspace {workspace} not found"), + }, + } + } + + Request::WorkspaceFullSummary { workspace } => { + let stats = match mgr.workspace_stats(workspace).await { + Some(s) => WorkspaceStatsInfo { + 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, + source: s.source, + uptime_ms: s.uptime_ms, + }, + None => return Response::Error { message: format!("workspace {workspace} not found") }, + }; + let quota = match mgr.workspace_quota(workspace).await { + Some(q) => QuotaReportInfo { + mem_limit: q.mem_limit, + nproc_limit: q.nproc_limit, + breaches: q.breaches, + }, + None => QuotaReportInfo { mem_limit: None, nproc_limit: None, breaches: Vec::new() }, + }; + let commands = mgr + .list_commands(workspace) + .await + .into_iter() + .map(|c| ProtoCommandInfo { + id: c.id, + label: c.label, + pid: c.pid, + alive: c.alive, + exit_status: c.exit_status, + log_bytes: c.log_bytes, + }) + .collect(); + // Flow sockets de pipelines whose workspace == este. + let flow_sockets = mgr + .list_flow_pipelines() + .await + .into_iter() + .flat_map(|(_, sockets)| sockets) + .collect(); + Response::WorkspaceFullSummary { stats, quota, commands, flow_sockets } + } + + Request::WorkspaceQuota { workspace } => match mgr.workspace_quota(workspace).await { + Some(q) => Response::WorkspaceQuota { + info: QuotaReportInfo { + mem_limit: q.mem_limit, + nproc_limit: q.nproc_limit, + breaches: q.breaches, + }, + }, + None => Response::Error { + message: format!("workspace {workspace} not found"), + }, + }, + + Request::FlowList => { + let items = mgr + .list_flow_pipelines() + .await + .into_iter() + .map(|(pipeline, sockets)| FlowInfo { pipeline, sockets }) + .collect(); + Response::FlowList { items } + } + + Request::FlowThroughput => { + let items = mgr + .flow_throughput() + .await + .into_iter() + .map(|(socket, bytes_total, bytes_per_sec)| FlowThroughputInfo { + socket, + bytes_total, + bytes_per_sec, + }) + .collect(); + Response::FlowThroughput { items } + } + + Request::FlowDrop { pipeline } => { + let existed = mgr.drop_pipeline_flows(pipeline).await; + Response::FlowDropped { pipeline, existed } + } + + Request::Capabilities => { + let c = mgr.incarnator().capabilities(); + Response::Capabilities { + kernel_version: c.kernel_version, + user_ns: format!("{:?}", c.user_ns), + cgroup_v2: format!("{:?}", c.cgroup_v2), + cgroup_delegated: c.cgroup_delegated, + has_cap_sys_admin: c.has_cap_sys_admin, + } + } + + // `ExecStream`/`ExecPty` se atienden inline en `handle_client` con + // sus subprotocolos; los frames `PtyInput`/`PtyResize` sólo viven + // dentro de un `ExecPty` ya en curso. Nunca deberían llegar a + // `dispatch` (request/response 1:1). Si lo hacen, error explícito. + Request::ExecStream { .. } + | Request::ExecPty { .. } + | Request::PtyInput { .. } + | Request::PtyResize { .. } => Response::Error { + message: "frame de streaming/PTY fuera de su subprotocolo; no por dispatch".into(), + }, + } +} + +fn map_edge_to_info(e: shuma_core::pipeline::EdgeDiscernment) -> EdgeDiscernmentInfo { + EdgeDiscernmentInfo { + from_label: e.from_label, + from_output: e.from_output, + to_label: e.to_label, + to_input: e.to_input, + ty: e.discernment.as_ref().map(|d| format!("{:?}", d.ty)), + mime: e.discernment.as_ref().and_then(|d| d.mime.clone()), + lens: e.discernment.as_ref().and_then(|d| d.lens.clone()), + confidence: e.discernment.as_ref().map(|d| d.confidence).unwrap_or(0.0), + flow_socket: e.flow_socket, + } +} + +/// Por cada edge con TypeRef detectado, spawneamos una Card efímera en el +/// SidecarPool que se anuncia al broker como producer del TypeRef +/// enriquecido. Esto permite a otros explorers (broker-explorer, etc.) +/// ver que shuma vio JSON/text/wasm/etc. saliendo de un pipeline. +fn announce_edges_to_broker( + pool: Option<&card_sidecar::SidecarPool>, + pipeline: &ulid::Ulid, + edges: &[shuma_core::pipeline::EdgeDiscernment], +) { + let Some(pool) = pool else { return }; + for e in edges { + let Some(d) = &e.discernment else { continue }; + let label = format!( + "shuma.flow.{}.{}.{}.{}", + short_ulid(pipeline), + e.from_label, + e.from_output, + type_label(&d.ty) + ); + let mut card = Card::new(label); + card.kind = CardKind::Data; + card.lifecycle = Lifecycle::Oneshot; + card.payload = Payload::Virtual; + card.supervision = Supervision::OneShot; + card.flow = Flows { + input: Vec::new(), + output: vec![Flow { + name: e.from_output.clone(), + ty: d.ty.clone(), + pin_to: None, + }], + }; + pool.spawn(card); + info!(pipeline = %pipeline, from = %e.from_label, ty = ?d.ty, "edge announced to broker"); + } +} + +fn short_ulid(u: &ulid::Ulid) -> String { + let s = u.to_string(); + s[s.len() - 6..].to_string() +} + +fn type_label(t: &TypeRef) -> String { + match t { + TypeRef::Primitive { name } => name.clone(), + TypeRef::Wit { package, name, .. } => format!("{package}.{name}"), + } +} + +/// Card de un Workspace recién creado. Se publica al pool para que el +/// broker-explorer (y consumidores futuros) puedan listar los +/// workspaces vivos sin pasar por el daemon. La card es `Ente` +/// (entidad viva), `Lifecycle::Daemon` (vive lo que el workspace), y +/// expone un flow `commands` que un consumidor podría suscribir. +fn build_workspace_card(label: &str, id: shuma_card::WorkspaceId) -> Card { + let card_label = format!("shuma.workspace.{}.{}", short_workspace_id(&id), label); + let mut card = Card::new(card_label); + card.kind = CardKind::Ente; + card.lifecycle = Lifecycle::Daemon; + card.payload = Payload::Virtual; + card.supervision = Supervision::Delegate; + card.flow = Flows { + input: Vec::new(), + output: vec![Flow { + name: "commands".into(), + ty: TypeRef::Wit { + package: "shuma:admin".into(), + interface: None, + name: "command-list".into(), + }, + pin_to: None, + }], + }; + card +} + +fn short_workspace_id(id: &shuma_card::WorkspaceId) -> String { + let s = id.to_string(); + s[s.len().saturating_sub(6)..].to_string() +} + +/// Card del daemon. La presentamos al broker así otras sesiones pueden +/// descubrir que shuma está corriendo y, eventualmente, conectarse +/// como consumidoras del flow `workspaces` (futuro: que la GUI o el +/// broker-explorer los listen vía broker en lugar de socket directo). +fn build_daemon_card(service_socket: &std::path::Path) -> Card { + let mut card = Card::new("shuma.daemon"); + card.kind = CardKind::Ente; + card.lifecycle = Lifecycle::Daemon; + card.payload = Payload::Virtual; // el daemon ya está corriendo (no es PID 1 quien lo encarna) + card.supervision = Supervision::Delegate; + card.service_socket = Some(service_socket.to_path_buf()); + card.flow = Flows { + input: Vec::new(), + output: vec![ + Flow { + name: "workspaces".into(), + ty: TypeRef::Wit { + package: "shuma:admin".into(), + interface: None, + name: "workspace-list".into(), + }, + pin_to: None, + }, + Flow { + name: "discern".into(), + ty: TypeRef::Wit { + package: "shuma:admin".into(), + interface: None, + name: "discernment".into(), + }, + pin_to: None, + }, + ], + }; + card +} + +fn init_tracing() { + use tracing_subscriber::{fmt, EnvFilter}; + let filter = EnvFilter::try_from_env("SHIPOTE_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + fmt().with_env_filter(filter).init(); +} + +/// Path del lockfile asociado al socket admin: mismo dir, extensión `.pid`. +fn pid_path_for(sock: &std::path::Path) -> std::path::PathBuf { + sock.with_extension("pid") +} + +/// ¿Hay un peer atendiendo el socket admin? Distingue stale (post-crash) +/// de vivo (otro daemon). Mismo patrón que arje-zero y sandokan-local. +fn socket_in_use(path: &std::path::Path) -> bool { + if !path.exists() { + return false; + } + std::os::unix::net::UnixStream::connect(path).is_ok() +} + +/// Adquiere un lock exclusivo no-bloqueante sobre `pid_path`, escribe el +/// PID actual y devuelve el `File` que sostiene el lock. Mientras el +/// `File` viva, el kernel garantiza que ningún otro proceso adquiera el +/// mismo lock (advisory, pero todos los daemones cooperan al llamar esto +/// antes de bindear). Cuando el `File` se drop —Drop ordenado o crash—, +/// el OS libera el lock; el `.pid` queda en disco pero ya no protege. +fn acquire_lockfile(pid_path: &std::path::Path) -> anyhow::Result { + use std::io::{Seek, SeekFrom, Write}; + use std::os::fd::AsRawFd; + + if let Some(parent) = pid_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // No truncamos al abrir: si flock falla, queremos preservar el PID + // viejo para que el mensaje de error sea informativo. + let mut f = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(pid_path) + .with_context(|| format!("abrir {}", pid_path.display()))?; + + let r = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if r != 0 { + let err = std::io::Error::last_os_error(); + let other_pid = std::fs::read_to_string(pid_path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "?".to_string()); + anyhow::bail!( + "lockfile {} ya tomado por PID {} ({err})", + pid_path.display(), + other_pid, + ); + } + + // Tenemos el lock: actualizamos el contenido al PID actual. + f.set_len(0)?; + f.seek(SeekFrom::Start(0))?; + writeln!(f, "{}", std::process::id())?; + f.flush()?; + Ok(f) +} + +#[cfg(test)] +mod tests { + use super::*; + use shuma_protocol::{ExecKind, ExecStage}; + + /// El subprotocolo ExecStream sirve un `echo` end-to-end sobre un par + /// de UnixStream — sin lanzar el binario del daemon. Comprueba que + /// los frames llegan en el orden esperado y terminan con `ExecExited`. + #[tokio::test] + async fn exec_stream_echoes_a_line() { + let (mut server, mut client) = tokio::net::UnixStream::pair().unwrap(); + // Server: corre el handler de streaming en tarea aparte. + let server_task = tokio::spawn(async move { + handle_exec_stream( + &mut server, + ".".into(), + ProtoExecKind::Direct { + stages: vec![ExecStage { + program: "echo".into(), + args: vec!["hola".into(), "mundo".into()], + }], + }, + 0, + None, + false, + ) + .await + .unwrap(); + }); + // Cliente: drena hasta terminal. + let mut frames: Vec = Vec::new(); + loop { + let r: Response = read_frame(&mut client).await.expect("read frame"); + let terminal = r.is_exec_terminal(); + frames.push(r); + if terminal { + break; + } + } + server_task.await.unwrap(); + let _ = ExecKind::Direct { stages: vec![] }; // silencia warning si quedara + // Esperamos al menos: un Stdout("hola mundo") y un ExecExited(0). + assert!( + frames.iter().any(|f| matches!(f, Response::ExecStdout(l) if l == "hola mundo")), + "no llegó la línea de stdout: {frames:?}" + ); + assert!(matches!(frames.last(), Some(Response::ExecExited(0)))); + } + + /// El daemon mata el proceso si el cliente cierra mitad de stream — + /// invariante crítica para no dejar zombies (convención SSH/PTY). + #[tokio::test] + async fn exec_stream_kills_child_on_client_disconnect() { + let (mut server, client) = tokio::net::UnixStream::pair().unwrap(); + let server_task = tokio::spawn(async move { + // `sleep 30` daría tiempo de sobra para detectar zombies; el + // test debería terminar en <1s gracias al EOF de write_frame. + let res = handle_exec_stream( + &mut server, + ".".into(), + ProtoExecKind::Direct { + stages: vec![ExecStage { + program: "sleep".into(), + args: vec!["30".into()], + }], + }, + 0, + None, + false, + ) + .await; + // Esperamos un error de I/O al intentar escribir tras el close; + // lo que importa es que la función retornó (no se colgó). + res + }); + // Le damos tiempo a arrancar el proceso, luego cerramos. + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + drop(client); + // Sin timeout aquí porque el daemon debe terminar rápido al + // detectar el EOF al escribir. Si el test cuelga, el invariante + // está roto. + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), server_task) + .await + .expect("handler colgado: posible zombie"); + } + + /// `ExecStream::Shell` también funciona (el daemon traduce el variante). + #[tokio::test] + async fn exec_stream_supports_shell_mode() { + let (mut server, mut client) = tokio::net::UnixStream::pair().unwrap(); + let server_task = tokio::spawn(async move { + handle_exec_stream( + &mut server, + ".".into(), + ProtoExecKind::Shell { + line: "echo $((2 + 3))".into(), + program: "sh".into(), + }, + 0, + None, + false, + ) + .await + .unwrap(); + }); + let mut got = String::new(); + loop { + let r: Response = read_frame(&mut client).await.unwrap(); + if let Response::ExecStdout(l) = &r { + got = l.clone(); + } + if r.is_exec_terminal() { + break; + } + } + server_task.await.unwrap(); + assert_eq!(got, "5"); + } + + /// Con `capture_stages: true`, un pipe `Direct` de dos etapas debe + /// emitir el tee de la etapa intermedia como frames + /// `ExecStageStdout { stage: 0, .. }` además del stdout final. Es la + /// invariante del "live tee" sobre el transporte del daemon. + #[tokio::test] + async fn exec_stream_tees_intermediate_stage() { + let (mut server, mut client) = tokio::net::UnixStream::pair().unwrap(); + let server_task = tokio::spawn(async move { + handle_exec_stream( + &mut server, + ".".into(), + // `printf "a\nb\n" | cat` — la etapa 0 (printf) es la + // intermedia que se intercepta; la 1 (cat) da el final. + ProtoExecKind::Direct { + stages: vec![ + ExecStage { + program: "printf".into(), + args: vec!["a\\nb\\n".into()], + }, + ExecStage { program: "cat".into(), args: vec![] }, + ], + }, + 0, + None, + true, + ) + .await + .unwrap(); + }); + let mut frames: Vec = Vec::new(); + loop { + let r: Response = read_frame(&mut client).await.expect("read frame"); + let terminal = r.is_exec_terminal(); + frames.push(r); + if terminal { + break; + } + } + server_task.await.unwrap(); + // Debe haber al menos un frame de tee de la etapa 0. + assert!( + frames + .iter() + .any(|f| matches!(f, Response::ExecStageStdout { stage: 0, line } if line == "a")), + "no llegó el tee de la etapa intermedia: {frames:?}" + ); + // Y el stdout final ("b" o "a"/"b") debe seguir llegando aparte. + assert!( + frames.iter().any(|f| matches!(f, Response::ExecStdout(_))), + "no llegó el stdout final: {frames:?}" + ); + assert!(matches!(frames.last(), Some(Response::ExecExited(0)))); + } + + // ----------------------------------------------------------------- + // Singleton del daemon: socket_in_use + flock(LOCK_EX | LOCK_NB) + // ----------------------------------------------------------------- + + #[test] + fn pid_path_es_sock_con_extension_pid() { + let p = pid_path_for(std::path::Path::new("/run/foo.sock")); + assert_eq!(p, std::path::PathBuf::from("/run/foo.pid")); + } + + #[test] + fn socket_in_use_false_para_path_inexistente() { + let tmp = tempfile::TempDir::new().unwrap(); + let p = tmp.path().join("ausente.sock"); + assert!(!socket_in_use(&p)); + } + + #[test] + fn lockfile_se_adquiere_y_escribe_el_pid_actual() { + let tmp = tempfile::TempDir::new().unwrap(); + let p = tmp.path().join("shuma.pid"); + let _guard = acquire_lockfile(&p).expect("primer lock"); + let leido = std::fs::read_to_string(&p).expect("read pid"); + assert_eq!(leido.trim(), std::process::id().to_string()); + } + + #[test] + fn lockfile_segundo_lock_en_el_mismo_archivo_falla() { + // Dos handles distintos al MISMO path: el segundo flock debe + // fallar con EWOULDBLOCK. Equivale al escenario "dos daemones + // arrancan a la vez". + let tmp = tempfile::TempDir::new().unwrap(); + let p = tmp.path().join("compit.pid"); + let _primer = acquire_lockfile(&p).expect("primer lock"); + let err = acquire_lockfile(&p) + .expect_err("el segundo debe ser rechazado por el kernel"); + let msg = format!("{err:#}"); + assert!( + msg.contains("ya tomado") || msg.contains("PID"), + "mensaje no enuncia conflicto: {msg}" + ); + } + + #[test] + fn lockfile_drop_del_primer_handle_libera_el_lock() { + let tmp = tempfile::TempDir::new().unwrap(); + let p = tmp.path().join("relevo.pid"); + // Sacamos el lock + lo droppeamos. + let primer = acquire_lockfile(&p).expect("primer lock"); + drop(primer); + // El segundo debe tomar el lock sin error. + let _segundo = + acquire_lockfile(&p).expect("tras drop del primero, el segundo debe poder lockear"); + } +} diff --git a/02_ruway/shuma/shuma-gateway/Cargo.toml b/02_ruway/shuma/shuma-gateway/Cargo.toml new file mode 100644 index 0000000..6cfd01c --- /dev/null +++ b/02_ruway/shuma/shuma-gateway/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "shuma-gateway" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "HTTP/JSON gateway para shipote — traduce JSON ↔ postcard contra el admin socket." + +[[bin]] +name = "shuma-gateway" +path = "src/main.rs" + +[dependencies] +shuma-protocol = { path = "../sandbox/shuma-protocol" } +anyhow = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/02_ruway/shuma/shuma-gateway/LEEME.md b/02_ruway/shuma/shuma-gateway/LEEME.md new file mode 100644 index 0000000..46d71d6 --- /dev/null +++ b/02_ruway/shuma/shuma-gateway/LEEME.md @@ -0,0 +1,9 @@ +# shuma-gateway + +> Gateway de sesiones remotas de [shuma](../README.md). + +Proxy entre cliente local y daemons remotos. Útil en redes corporativas donde sólo un host tiene acceso directo al destino. + +## Deps + +- [`shuma-protocol`](../shuma-protocol/README.md) diff --git a/02_ruway/shuma/shuma-gateway/README.md b/02_ruway/shuma/shuma-gateway/README.md new file mode 100644 index 0000000..9812b60 --- /dev/null +++ b/02_ruway/shuma/shuma-gateway/README.md @@ -0,0 +1,9 @@ +# shuma-gateway + +> Remote-session gateway of [shuma](../README.md). + +Proxy between local client and remote daemons. Useful on corporate networks where only one host has direct access to the destination. + +## Deps + +- [`shuma-protocol`](../sandbox/shuma-protocol/README.md) diff --git a/02_ruway/shuma/shuma-gateway/src/main.rs b/02_ruway/shuma/shuma-gateway/src/main.rs new file mode 100644 index 0000000..9f77a43 --- /dev/null +++ b/02_ruway/shuma/shuma-gateway/src/main.rs @@ -0,0 +1,168 @@ +//! `shuma-gateway` — HTTP/JSON adapter para el daemon. +//! +//! Acepta `POST /rpc` con body JSON serializado como `shuma_protocol::Request`, +//! hace round-trip al admin socket via postcard, devuelve `Response` como JSON. +//! +//! Diseñado para clients no-Rust (curl, Python, web app) que no pueden +//! hablar postcard directo. NO es un proxy completo — sólo translation +//! layer del protocolo. +//! +//! Sin dep de axum/hyper: HTTP parser ad-hoc, suficiente para 1 endpoint. + +use shuma_protocol::{default_socket_path, read_frame, write_frame, Request, Response}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream, UnixStream}; +use tracing::{info, warn}; + +const DEFAULT_LISTEN: &str = "127.0.0.1:7378"; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + let listen = std::env::var("SHIPOTE_GATEWAY_LISTEN").unwrap_or_else(|_| DEFAULT_LISTEN.into()); + let daemon_sock = Arc::new(default_socket_path()); + let listener = TcpListener::bind(&listen).await?; + info!(listen = %listen, daemon = %daemon_sock.display(), "shuma-gateway listening"); + + loop { + match listener.accept().await { + Ok((stream, peer)) => { + let sock = daemon_sock.clone(); + tokio::spawn(async move { + if let Err(e) = handle_http(stream, sock).await { + warn!(?e, ?peer, "request error"); + } + }); + } + Err(e) => { + warn!(?e, "accept failed"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } +} + +async fn handle_http(mut stream: TcpStream, daemon_sock: Arc) -> anyhow::Result<()> { + // Parser HTTP mínimo: read hasta `\r\n\r\n`, parsear request line + + // Content-Length, después leer body exacto. + let mut buf = Vec::with_capacity(4096); + let mut tmp = [0u8; 4096]; + let header_end; + loop { + let n = stream.read(&mut tmp).await?; + if n == 0 { + return Ok(()); // closed + } + buf.extend_from_slice(&tmp[..n]); + if let Some(pos) = find_double_crlf(&buf) { + header_end = pos + 4; + break; + } + if buf.len() > 64 * 1024 { + return write_error(&mut stream, 413, "headers too large").await; + } + } + + let header_str = std::str::from_utf8(&buf[..header_end - 4])?; + let mut lines = header_str.lines(); + let request_line = lines.next().unwrap_or(""); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or(""); + let path = parts.next().unwrap_or(""); + let mut content_length: usize = 0; + for line in lines { + if let Some(v) = line.strip_prefix("Content-Length:").or_else(|| line.strip_prefix("content-length:")) { + content_length = v.trim().parse().unwrap_or(0); + } + } + + // Rutas: + if method == "GET" && (path == "/" || path == "/health") { + return write_text(&mut stream, 200, "shuma-gateway ok\n").await; + } + if method != "POST" || path != "/rpc" { + return write_error(&mut stream, 404, "use POST /rpc").await; + } + + // Leer body. + let mut body = buf[header_end..].to_vec(); + while body.len() < content_length { + let n = stream.read(&mut tmp).await?; + if n == 0 { + break; + } + body.extend_from_slice(&tmp[..n]); + } + body.truncate(content_length); + + // Parsear JSON → Request. + let req: Request = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(e) => return write_error(&mut stream, 400, &format!("bad json: {e}")).await, + }; + + // Round-trip al daemon. + let resp = match round_trip_daemon(&daemon_sock, &req).await { + Ok(r) => r, + Err(e) => return write_error(&mut stream, 502, &format!("daemon: {e}")).await, + }; + + // Serializar Response → JSON. + let body_json = serde_json::to_vec(&resp)?; + write_response(&mut stream, 200, "application/json", &body_json).await +} + +async fn round_trip_daemon(sock: &std::path::Path, req: &Request) -> anyhow::Result { + let mut stream = UnixStream::connect(sock).await?; + write_frame(&mut stream, req).await?; + let resp: Response = read_frame(&mut stream).await?; + Ok(resp) +} + +fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +async fn write_response( + stream: &mut TcpStream, + code: u16, + content_type: &str, + body: &[u8], +) -> anyhow::Result<()> { + let status = match code { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 413 => "Payload Too Large", + 502 => "Bad Gateway", + _ => "Unknown", + }; + let head = format!( + "HTTP/1.1 {code} {status}\r\n\ + Content-Type: {content_type}\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n", + body.len() + ); + stream.write_all(head.as_bytes()).await?; + stream.write_all(body).await?; + stream.flush().await?; + Ok(()) +} + +async fn write_text(stream: &mut TcpStream, code: u16, body: &str) -> anyhow::Result<()> { + write_response(stream, code, "text/plain", body.as_bytes()).await +} + +async fn write_error(stream: &mut TcpStream, code: u16, msg: &str) -> anyhow::Result<()> { + let body = serde_json::json!({ "error": msg }).to_string(); + write_response(stream, code, "application/json", body.as_bytes()).await +} + +fn init_tracing() { + use tracing_subscriber::{fmt, EnvFilter}; + let filter = EnvFilter::try_from_env("SHIPOTE_GATEWAY_LOG").unwrap_or_else(|_| EnvFilter::new("info")); + fmt().with_env_filter(filter).init(); +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/Cargo.toml b/02_ruway/shuma/shuma-shell-llimphi/Cargo.toml new file mode 100644 index 0000000..b2fcbf8 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "shuma-shell-llimphi" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "shuma-shell-llimphi — chasis Llimphi del shell shuma con sistema de módulos enchufables: tabs principales por módulo, stack de monitores en el panel derecho y toolbar de shortcuts en el header. Reemplazo de la versión GPUI; los módulos vienen aparte." + +[[bin]] +name = "shuma-shell-llimphi" +path = "src/main.rs" + +[dependencies] +shuma-module = { path = "../sandbox/shuma-module" } +shuma-module-commandbar = { path = "../sandbox/shuma-module-commandbar" } +shuma-module-launcher = { path = "../sandbox/shuma-module-launcher" } +shuma-module-matilda = { path = "../sandbox/shuma-module-matilda" } +matilda-core = { path = "../baremetal/matilda-core" } +shuma-module-minga = { path = "../sandbox/shuma-module-minga" } +minga-core = { workspace = true } +shuma-module-shell = { path = "../sandbox/shuma-module-shell" } +shuma-module-canvas = { path = "../sandbox/shuma-module-canvas" } +shuma-intent = { path = "../sandbox/shuma-intent" } +shuma-sysmon = { path = "../sandbox/shuma-sysmon" } +pata-host = { workspace = true } +llimphi-ui = { workspace = true } +llimphi-theme = { workspace = true } +llimphi-widget-tabs = { workspace = true } +llimphi-widget-splitter = { workspace = true } +llimphi-widget-stat-card = { workspace = true } +llimphi-widget-menubar = { workspace = true } +llimphi-widget-context-menu = { workspace = true } +llimphi-motion = { workspace = true } +app-bus = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +directories = { workspace = true } +rimay-localize = { workspace = true } +wawa-config = { workspace = true } +wawa-config-llimphi = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/02_ruway/shuma/shuma-shell-llimphi/LEEME.md b/02_ruway/shuma/shuma-shell-llimphi/LEEME.md new file mode 100644 index 0000000..7ab4b62 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/LEEME.md @@ -0,0 +1,15 @@ +# shuma-shell-llimphi + +> Shell con UI Llimphi de [shuma](../README.md). + +App del chasis 4-slot: TopBar (command bar), Main (output stream), BottomBar (status), DrawerTab (Quake drawer). Cada slot es un módulo intercambiable. + +## Uso + +```sh +cargo run --release -p shuma-shell-llimphi +``` + +## Deps + +- Todos los `shuma-module-*`, [`shuma-shell-render`](../shuma-shell-render/README.md) diff --git a/02_ruway/shuma/shuma-shell-llimphi/README.md b/02_ruway/shuma/shuma-shell-llimphi/README.md new file mode 100644 index 0000000..2b7a070 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/README.md @@ -0,0 +1,15 @@ +# shuma-shell-llimphi + +> Llimphi UI shell of [shuma](../README.md). + +App on the 4-slot chassis: TopBar (command bar), Main (output stream), BottomBar (status), DrawerTab (Quake drawer). Each slot is a swappable module. + +## Usage + +```sh +cargo run --release -p shuma-shell-llimphi +``` + +## Deps + +- All `shuma-module-*`, [`shuma-shell-render`](../sandbox/shuma-shell-render/README.md) diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/config.rs b/02_ruway/shuma/shuma-shell-llimphi/src/config.rs new file mode 100644 index 0000000..646da27 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/config.rs @@ -0,0 +1,252 @@ +//! Configuración del chasis: lectura del `shumarc-modules.toml`. +//! +//! El config controla qué módulos ocupan cada slot y con qué +//! parámetros. Cualquier `id` que no esté compilado en este binario +//! se ignora con un warning a stderr — un shumarc no debe romper el +//! arranque. +//! +//! Esquema: +//! +//! ```toml +//! [topbar] +//! module = "launcher" +//! +//! [bottombar] +//! module = "command-bar" +//! +//! [main] +//! module = "matilda" # opcional: si está, ocupa toda el área +//! source = { kind = "local" } +//! inventory = "/etc/matilda/local.json" +//! +//! [[tabs]] +//! id = "shell" +//! source = { kind = "local" } +//! label = "Shell" +//! +//! [[tabs]] +//! id = "matilda" +//! source = { kind = "remote", host = "edge-1", user = "ops" } +//! label = "edge-1" +//! inventory = "/etc/matilda/edge-1.json" +//! ``` +//! +//! Defaults aplicables: +//! - Sin `[topbar]` → launcher (lee `$XDG_CONFIG_HOME/shuma/apps/`). +//! - Sin `[bottombar]` → command-bar local. +//! - Sin `[main]` → tabs cubren el área (con monitores a la derecha). +//! - Sin `[[tabs]]` → shell + lienzo + matilda locales. +//! +//! El chasis ya no es un Quake-drawer — shuma es la app standalone +//! "normal" del workspace. La metáfora overlay/F12 vive en +//! `mirada-launcher-llimphi`, no acá. + +use serde::Deserialize; +use shuma_module::Source; +use std::path::{Path, PathBuf}; + +/// Una entrada simple "qué módulo + opciones" para los slots TopBar/ +/// Main/BottomBar. Sin label porque la barra superior/inferior no las +/// muestra y el Main usa el label canónico del módulo. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct SlotEntry { + /// `id` del módulo a activar. + pub module: String, + #[serde(default)] + pub source: Source, + /// Override de label (donde aplique). + #[serde(default)] + pub label: Option, + /// Path opcional a un inventario JSON (módulos como matilda lo + /// consumen para arrancar contra un servidor real en lugar del + /// inventario de ejemplo). + #[serde(default)] + pub inventory: Option, +} + +/// Una entrada del array `[[tabs]]`. Mismo shape que [`SlotEntry`] +/// pero con el `id` separado del campo `module` por convención del +/// shumarc. +#[derive(Debug, Clone, Deserialize)] +pub struct TabEntry { + /// `id` del módulo a activar como tab. + pub id: String, + #[serde(default)] + pub source: Source, + #[serde(default)] + pub label: Option, + /// Path opcional a un inventario JSON — ver [`SlotEntry::inventory`]. + #[serde(default)] + pub inventory: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ShumaConfig { + pub topbar: Option, + pub bottombar: Option, + pub main: Option, + #[serde(default)] + pub tabs: Vec, +} + +impl ShumaConfig { + /// Ruta canónica: `$XDG_CONFIG_HOME/shuma/shumarc-modules.toml`. + /// Es **distinto** del `shumarc.toml` clásico (aliases/env/prompt) + /// para que el chasis no acople su parseo al de `shuma-config`. + pub fn default_path() -> Option { + directories::ProjectDirs::from("", "", "shuma") + .map(|d| d.config_dir().join("shumarc-modules.toml")) + } + + /// Lee el config del path. Si no existe, devuelve `Self::default()` + /// sin error. Si está mal formado, log a stderr y devuelve default + /// (un shumarc roto no debe impedir arrancar el shell). + pub fn load(path: &Path) -> Self { + if !path.exists() { + return Self::default(); + } + match std::fs::read_to_string(path) { + Ok(text) => match toml::from_str(&text) { + Ok(cfg) => cfg, + Err(e) => { + eprintln!( + "shuma: {} mal formado ({e}), uso defaults", + path.display() + ); + Self::default() + } + }, + Err(e) => { + eprintln!("shuma: no se pudo leer {} ({e})", path.display()); + Self::default() + } + } + } + + /// Lee el config del path por defecto. Si no hay `ProjectDirs` + /// (caso raro), devuelve defaults. + pub fn load_default() -> Self { + match Self::default_path() { + Some(p) => Self::load(&p), + None => Self::default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn missing_file_yields_default() { + let d = tempdir().unwrap(); + let c = ShumaConfig::load(&d.path().join("nope.toml")); + assert!(c.topbar.is_none()); + assert!(c.tabs.is_empty()); + } + + #[test] + fn parses_a_full_shumarc() { + let d = tempdir().unwrap(); + let path = d.path().join("shumarc-modules.toml"); + std::fs::write( + &path, + r#" +[topbar] +module = "launcher" + +[bottombar] +module = "command-bar" + +[main] +module = "matilda" +source = { kind = "local" } +label = "Servidores" + +[[tabs]] +id = "shell" +source = { kind = "local" } +label = "Shell" + +[[tabs]] +id = "matilda" +source = { kind = "remote", host = "edge-1.example", user = "deploy" } +label = "edge-1" +"#, + ) + .unwrap(); + + let c = ShumaConfig::load(&path); + assert_eq!(c.topbar.unwrap().module, "launcher"); + assert_eq!(c.bottombar.unwrap().module, "command-bar"); + let main = c.main.unwrap(); + assert_eq!(main.module, "matilda"); + assert_eq!(main.label.as_deref(), Some("Servidores")); + assert_eq!(c.tabs.len(), 2); + assert_eq!(c.tabs[0].id, "shell"); + match &c.tabs[1].source { + Source::Remote { host, user, .. } => { + assert_eq!(host, "edge-1.example"); + assert_eq!(user, "deploy"); + } + _ => panic!("expected Remote"), + } + } + + #[test] + fn broken_toml_returns_default_without_panic() { + let d = tempdir().unwrap(); + let path = d.path().join("shumarc-modules.toml"); + std::fs::write(&path, "this = is { broken").unwrap(); + let c = ShumaConfig::load(&path); + assert!(c.topbar.is_none()); // default + } + + #[test] + fn inventory_field_parses_on_main_and_tabs() { + let d = tempdir().unwrap(); + let path = d.path().join("p.toml"); + std::fs::write( + &path, + r#" +[main] +module = "matilda" +inventory = "/etc/matilda/edge.json" + +[[tabs]] +id = "matilda" +inventory = "/etc/matilda/edge2.json" +"#, + ) + .unwrap(); + let c = ShumaConfig::load(&path); + assert_eq!( + c.main.unwrap().inventory.as_deref(), + Some(std::path::Path::new("/etc/matilda/edge.json")) + ); + assert_eq!( + c.tabs[0].inventory.as_deref(), + Some(std::path::Path::new("/etc/matilda/edge2.json")) + ); + } + + #[test] + fn partial_config_falls_back_to_defaults_per_field() { + let d = tempdir().unwrap(); + let path = d.path().join("p.toml"); + std::fs::write( + &path, + r#" +[main] +module = "shell" +"#, + ) + .unwrap(); + let c = ShumaConfig::load(&path); + assert!(c.topbar.is_none()); + assert!(c.bottombar.is_none()); + assert_eq!(c.main.as_ref().unwrap().module, "shell"); + assert!(c.tabs.is_empty()); + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/main.rs b/02_ruway/shuma/shuma-shell-llimphi/src/main.rs new file mode 100644 index 0000000..39df94c --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/main.rs @@ -0,0 +1,675 @@ +//! `shuma-shell-llimphi` — chasis del shell shuma sobre Llimphi. +//! +//! Shuma es la app standalone "normal" del workspace: una ventana con +//! tabs siempre visibles, monitores a la derecha, command-bar abajo. La +//! metáfora Quake-drawer (overlay sobre el escritorio + F12 para +//! invocar) vive en `mirada-launcher-llimphi`, no acá. +//! +//! **Layout** (sin `[main]` en shumarc): +//! +//! ```text +//! ┌──────────────────────────────────────────────────┐ +//! │ TopBar · launcher (apps + shortcuts) │ +//! ├────────────────────────────────┬─────────────────┤ +//! │ tabs: [shell] [lienzo] [matilda]│ │ +//! ├────────────────────────────────┤ Monitores │ +//! │ │ CPU + MEM + │ +//! │ contenido del tab activo │ los del módulo │ +//! │ │ │ +//! ├────────────────────────────────┴─────────────────┤ +//! │ BottomBar · command-bar › escribí… │ +//! └──────────────────────────────────────────────────┘ +//! ``` +//! +//! Si el shumarc declara `[main]`, ese módulo ocupa toda el área central +//! a pantalla completa (sin tabs ni monitores) — útil para correr shuma +//! como wrapper de matilda standalone, por ejemplo. +//! +//! El chasis no conoce a sus módulos: el `Kind` estático enumera los +//! compilados. El shumarc elige cuáles activar y en qué slot. + +#![forbid(unsafe_code)] + +mod config; + +use std::time::Duration; + +use llimphi_motion::{animate, motion, Tween}; +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::{ + prelude::{length, percent, Dimension, FlexDirection, Size, Style}, + Rect, +}; +use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, PathEl, Point, Stroke}; +use llimphi_ui::llimphi_raster::peniko::Color; +use llimphi_ui::{ + App, DragPhase, Handle, KeyEvent, KeyState, Modifiers, PaintRect, View, WheelDelta, +}; +use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette}; +use llimphi_widget_stat_card::{stat_card_view, StatCardPalette}; +use llimphi_widget_tabs::{tabs_view, TabsPalette, TabsSpec}; +use shuma_module::{ModuleContributions, MonitorSpec, ShortcutAction, ShortcutSpec, Source}; +use shuma_sysmon::{Snapshot, SystemSampler}; +use std::collections::HashMap; + +const HISTORY: usize = 60; +const TICK: Duration = Duration::from_secs(1); +/// Cadencia rápida para drenar el output del shell (streaming de +/// `shuma-exec`). 1 Hz se siente lento al ver `for i in …; do echo $i; +/// sleep 0.1; done`; 100 ms hace la salida sentirse en vivo sin +/// comerse CPU notable. +const SHELL_TICK: Duration = Duration::from_millis(100); +const MONITORS_INITIAL_WIDTH: f32 = 280.0; + +/// Id del diente "Monitores" en el rail hospedado de pata. Los dientes de +/// las tabs usan su índice (`0..tabs.len()`); este sentinela alto no choca +/// con ningún índice real y togglea el panel de monitores. +const MONITORS_TOOTH: u32 = u32::MAX; + +/// Construye el cliente del rail hospedado si `SHUMA_DELEGATE_SIDEBAR` está +/// set. shuma publica sus tabs como dientes (cambian de tab al activarse) + +/// un diente "Monitores" que togglea el panel derecho. Cuando shuma tiene +/// foco, esos dientes aparecen en el rail global de pata; el área central +/// queda como puro lienzo (monitores ocultos por default). `app_id` debe ser +/// el mismo que reporta el compositor (`Shell::app_id`). +fn shuma_host(handle: &Handle, tabs: &[Instance]) -> Option { + if std::env::var_os("SHUMA_DELEGATE_SIDEBAR").is_none() { + return None; + } + let teeth = host_teeth(tabs); + let h = handle.clone(); + pata_host::HostClient::connect("shuma.shell", "shuma", teeth, move |id| { + h.dispatch(Msg::HostActivate(id)) + }) +} + +/// Dientes que shuma presta al rail de pata: uno por tab (id = índice) más el +/// toggle de monitores. +fn host_teeth(tabs: &[Instance]) -> Vec { + let mut teeth: Vec = tabs + .iter() + .enumerate() + .map(|(i, inst)| pata_host::HostedTooth::new(i as u32, tooth_icon(inst.kind), inst.label.clone())) + .collect(); + teeth.push(pata_host::HostedTooth::new( + MONITORS_TOOTH, + "system", + rimay_localize::t("shuma-label-monitors"), + )); + teeth +} + +/// Icono (vocabulario abierto de `pata`) para el diente de una tab según su +/// `Kind`. pata mapea estos nombres a sus formas (`files`→carpeta, +/// `tools`/`settings`→grupo, `monads`→mónada). +fn tooth_icon(kind: Kind) -> &'static str { + match kind { + Kind::Shell => "tools", + Kind::Matilda => "settings", + Kind::Minga => "monads", + Kind::Canvas => "files", + Kind::Launcher | Kind::CommandBar => "tools", + } +} + +/// `Source` por defecto de la tab shell según las env vars del proceso — +/// para que `SHUMA_REMOTE*` enrute los comandos al daemon sin shumarc. +/// (rescate del `detect_remote_transport` del shell GPUI): +/// +/// - `SHUMA_REMOTE_TCP_ADDR=host:port` + `SHUMA_REMOTE_TCP_PUB=` +/// → TCP autenticado Noise XK (`DaemonTcp`). La keypair propia la carga +/// `start_run` al conectar; acá sólo pasamos addr + pubkey del server. +/// - `SHUMA_REMOTE_SOCKET=/path` → daemon por ese Unix socket. +/// - `SHUMA_REMOTE=1` → daemon por el socket canónico (`socket: None`). +/// - sin ninguna → `Local` (ejecución directa). +fn default_shell_source() -> Source { + let nonempty = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + if let (Some(addr), Some(pub_hex)) = ( + nonempty("SHUMA_REMOTE_TCP_ADDR"), + nonempty("SHUMA_REMOTE_TCP_PUB"), + ) { + return Source::DaemonTcp { + addr, + server_pub_hex: pub_hex, + label: None, + }; + } + if let Some(path) = nonempty("SHUMA_REMOTE_SOCKET") { + return Source::Daemon { + socket: Some(std::path::PathBuf::from(path)), + label: None, + }; + } + if std::env::var("SHUMA_REMOTE").as_deref() == Ok("1") { + return Source::Daemon { + socket: None, + label: None, + }; + } + Source::Local +} + +fn main() { + rimay_localize::init(); + llimphi_ui::run::(); +} + +// ─── Tipos de módulos conocidos por este binario ─────────────────── + +/// Qué `Kind` puede ocupar cada slot. Una variante por módulo +/// compilado: agregar uno nuevo (p. ej. `matilda`) es una variante + +/// ramas en `update`/`view`. El static dispatch sortea la ausencia de +/// `View::map` en llimphi-ui. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Launcher, + CommandBar, + Shell, + Matilda, + Minga, + Canvas, +} + +impl Kind { + /// `id` canónico — bloque 5 lo usa para matchear shumarc. + #[allow(dead_code)] + fn id(self) -> &'static str { + match self { + Kind::Launcher => shuma_module_launcher::ID, + Kind::CommandBar => shuma_module_commandbar::ID, + Kind::Shell => shuma_module_shell::ID, + Kind::Matilda => shuma_module_matilda::ID, + Kind::Minga => shuma_module_minga::ID, + Kind::Canvas => shuma_module_canvas::ID, + } + } +} + +/// State vivo de un módulo. Una variante por `Kind` para evitar trait +/// objects (cada módulo trae su propio `Msg` que no es object-safe). +enum ModuleState { + Launcher(shuma_module_launcher::State), + CommandBar(shuma_module_commandbar::State), + Shell(shuma_module_shell::State), + // `State` de matilda lleva el inventory entero (varios cientos + // de bytes); boxearlo mantiene el enum ModuleState compacto. + Matilda(Box), + Minga(shuma_module_minga::State), + Canvas(shuma_module_canvas::State), +} + +/// Una instancia activa de un módulo. `kind` + `state` deben coincidir +/// (lo invariante lo garantiza el constructor). +struct Instance { + kind: Kind, + label: String, + state: ModuleState, +} + +impl Instance { + fn launcher(state: shuma_module_launcher::State) -> Self { + Self { + kind: Kind::Launcher, + label: rimay_localize::t("shuma-label-launcher"), + state: ModuleState::Launcher(state), + } + } + + fn command_bar(state: shuma_module_commandbar::State) -> Self { + Self { + kind: Kind::CommandBar, + label: rimay_localize::t("shuma-label-command"), + state: ModuleState::CommandBar(state), + } + } + + fn shell(label: String, source: Source) -> Self { + Self { + kind: Kind::Shell, + label, + state: ModuleState::Shell(shuma_module_shell::State::new(source)), + } + } + + fn matilda(label: String, source: Source) -> Self { + Self::matilda_with_inventory(label, source, None) + } + + fn matilda_with_inventory( + label: String, + source: Source, + inventory: Option<&std::path::Path>, + ) -> Self { + let state = match inventory { + Some(p) => { + let inv = load_matilda_inventory(p).unwrap_or_else(example_inventory_fallback); + shuma_module_matilda::State::with_inventory_path(source, inv, p.to_path_buf()) + } + None => shuma_module_matilda::State::new(source), + }; + Self { + kind: Kind::Matilda, + label, + state: ModuleState::Matilda(Box::new(state)), + } + } + + fn minga(label: String, source: Source) -> Self { + Self { + kind: Kind::Minga, + label, + state: ModuleState::Minga(shuma_module_minga::State::new(source)), + } + } + + fn canvas(label: String) -> Self { + Self { + kind: Kind::Canvas, + label, + state: ModuleState::Canvas(shuma_module_canvas::State::new()), + } + } +} + +#[derive(Debug, Clone)] +enum ModuleMsg { + Launcher(shuma_module_launcher::Msg), + CommandBar(shuma_module_commandbar::Msg), + #[allow(dead_code)] + Shell(shuma_module_shell::Msg), + Matilda(shuma_module_matilda::Msg), + Minga(shuma_module_minga::Msg), + Canvas(shuma_module_canvas::Msg), +} + +// ─── Slot del chasis al que va un Msg de módulo ──────────────────── + +/// Identifica de dónde viene un `ModuleMsg`. Los slots únicos (TopBar/ +/// Bottombar/Main) se identifican por sí mismos; el Tab lleva el +/// índice del tab para enrutar al instance correcto. +#[derive(Debug, Clone)] +enum Slot { + TopBar, + BottomBar, + #[allow(dead_code)] + Main, + Tab(usize), +} + +// ─── Modelo + Msg ─────────────────────────────────────────────────── + +struct Model { + theme: Theme, + + // Slots fijos (únicos): + topbar: Option, + bottombar: Option, + /// Si está set, ocupa toda el área central (sin tabs). Útil para + /// configurar shuma como wrapper de una sola app (matilda standalone, + /// editor, etc.) vía shumarc. + main: Option, + + // Tabs siempre visibles cuando `main` está vacío. + tabs: Vec, + active_tab: usize, + + // Monitor stack en el panel derecho del área central. + sysmon: SystemSampler, + last_snapshot: Option, + monitors_width: f32, + /// Historial por monitor extra (los que aportan los módulos vía + /// `contributions()`). La clave es `"/"`. El chasis + /// los muestrea en cada `Tick` y los acumula como `f32`. + extra_history: HashMap>, + /// Último `Sample::display` por monitor — se pinta como subtítulo + /// de la stat-card. + extra_display: HashMap, + /// Watcher del bus de config wawa. Vive lo que vive el modelo — + /// al dropear se cierran los notify::RecommendedWatcher y el thread + /// de debounce sale silenciosamente. Ningún read directo desde + /// el código de update — sólo recibe callbacks que se traducen a + /// `Msg::WawaConfigChanged`. + _wawa_watcher: Option, + + /// Menú principal: índice del menú raíz abierto (`None` = cerrado). + menu_open: Option, + /// Fila activa (resaltada por teclado) del dropdown del menú principal. + menu_active: usize, + /// Animación de aparición/swap del dropdown del menú principal (0→1). + menu_anim: Tween, + /// Menú contextual de terminal: ancla `(x, y)` en ventana (`None` = + /// cerrado). Se abre con right-click sobre el área de trabajo. + ctx_menu: Option<(f32, f32)>, + + /// Cliente del rail hospedado: con `SHUMA_DELEGATE_SIDEBAR`, shuma presta + /// sus tabs + el toggle de monitores al rail de pata. Kept-alive (las + /// activaciones llegan por callback → `Msg::HostActivate`); el `_` evita + /// el lint de campo sin leer, como `_wawa_watcher`. + _host: Option, + /// Visibilidad del panel de monitores. Sin delegar arranca `true` (siempre + /// visible); en modo delegado arranca oculto y lo controla el diente + /// "Monitores" del rail de pata. + monitors_visible: bool, +} + +#[derive(Clone)] +enum Msg { + Tick, + /// Tick rápido que drena la salida del shell (~100 ms) sin tocar + /// el muestreo de sysmon. + ShellTick, + /// Click en una tab. + SelectTab(usize), + /// Drag del splitter de monitores. + ResizeMonitors(f32), + /// Msg de un módulo. El chasis lo enruta a `update` según `slot`. + Module(Slot, ModuleMsg), + /// Click en un shortcut de la toolbar. `slot` es el módulo emisor + /// (a quien se le enruta la `ModuleAction`). + ShortcutClicked(Slot, ShortcutAction), + /// La config de wawa (`$XDG_CONFIG_HOME/wawa/config.json`) cambió; + /// rearmamos el theme, accent y locale sin reiniciar. Boxed por + /// tamaño (la config tiene un BTreeMap de módulos). + WawaConfigChanged(Box), + + /// Barra de menú principal: abrir/cerrar un menú raíz (`None` = cerrar). + MenuOpen(Option), + /// Navegación de teclado en el dropdown del menú principal (±1 fila). + MenuNav(i32), + /// Enter sobre la fila activa del menú principal. + MenuActivate, + /// Tick de re-render para la animación de aparición del dropdown. + MenuTick, + /// Comando elegido en el menú principal o contextual — se traduce al + /// `Msg`/acción real del chasis o del módulo shell focado. + MenuCommand(String), + /// Right-click sobre el área de trabajo → abre el menú contextual de + /// terminal en `(x, y)` de ventana. + ContextMenuOpen(f32, f32), + /// Cierra cualquier menú abierto (click-fuera / Esc). + CloseMenus, + + /// Rail hospedado de pata: el usuario activó un diente. `id < tabs.len()` + /// selecciona esa tab; `MONITORS_TOOTH` togglea el panel de monitores. + HostActivate(u32), +} + +struct Shell; + +impl App for Shell { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "shuma" + } + + fn app_id() -> Option<&'static str> { + Some("shuma.shell") + } + + fn initial_size() -> (u32, u32) { + (1280, 800) + } + + fn init(handle: &Handle) -> Self::Model { + handle.spawn_periodic(TICK, || Msg::Tick); + handle.spawn_periodic(SHELL_TICK, || Msg::ShellTick); + + // wawa-config (bus de preferencias del SO) — theme/accent/lang. + // Lo cargamos antes de armar las instancias para que el primer + // render ya tenga el theme correcto. El watcher avisa cambios + // posteriores con `Msg::WawaConfigChanged`. + let wawa = wawa_config::WawaConfig::load(); + let theme = wawa_config_llimphi::theme_from_wawa(&wawa, &Theme::dark()); + let _ = rimay_localize::set_locale(&wawa.lang); + let wawa_watcher = { + let handle = handle.clone(); + wawa_config::ConfigWatcher::spawn(move |cfg| { + handle.dispatch(Msg::WawaConfigChanged(Box::new(cfg))); + }) + .ok() + }; + + let cfg = config::ShumaConfig::load_default(); + let topbar = resolve_slot(cfg.topbar.as_ref()).or_else(|| { + Some(Instance::launcher( + shuma_module_launcher::State::from_apps_dir(), + )) + }); + let bottombar = resolve_slot(cfg.bottombar.as_ref()).or_else(|| { + Some(Instance::command_bar( + shuma_module_commandbar::State::default(), + )) + }); + let main = resolve_slot(cfg.main.as_ref()); + + let tabs = if cfg.tabs.is_empty() { + // Default cuando no hay `[[tabs]]`: shell + lienzo + matilda + // locales para que el chasis sea exploratorio desde el día + // uno sin que el usuario tenga que escribir un shumarc. El + // lienzo se mantiene en sync con el grafo del shell cada + // `SHELL_TICK` (~100 ms). + vec![ + Instance::shell(rimay_localize::t("shuma-label-shell"), default_shell_source()), + Instance::canvas(rimay_localize::t("shuma-label-canvas")), + Instance::matilda(rimay_localize::t("shuma-label-matilda"), Source::Local), + ] + } else { + cfg.tabs.iter().filter_map(resolve_tab).collect() + }; + + // Rail hospedado: si `SHUMA_DELEGATE_SIDEBAR` está set, prestamos las + // tabs + el toggle de monitores al rail de pata. Se conecta acá, una + // vez armadas las tabs, para publicar sus etiquetas como dientes. + let host = shuma_host(handle, &tabs); + // Sin delegar el panel siempre se ve; delegado arranca oculto (puro + // lienzo) y el rail de pata lo despliega. + let monitors_visible = host.is_none(); + + Model { + theme, + topbar, + bottombar, + main, + tabs, + active_tab: 0, + sysmon: SystemSampler::new(HISTORY), + last_snapshot: None, + monitors_width: MONITORS_INITIAL_WIDTH, + extra_history: HashMap::new(), + extra_display: HashMap::new(), + _wawa_watcher: wawa_watcher, + menu_open: None, + menu_active: usize::MAX, + menu_anim: Tween::idle(1.0), + ctx_menu: None, + _host: host, + monitors_visible, + } + } + + fn on_key(model: &Self::Model, e: &KeyEvent) -> Option { + if e.state != KeyState::Pressed { + return None; + } + // Con un menú abierto, Esc lo cierra y se come la tecla (no va al + // shell). El resto de teclas siguen su curso normal. + if let Some(msg) = menu::intercept_key(model, e) { + return Some(msg); + } + // Reenvía teclas al módulo focado. Hoy sólo el shell consume + // teclas (input del REPL); el resto de módulos siguen sin + // recibirlas hasta que las necesiten. + forward_key_to_focused_shell(model, e) + } + + fn on_wheel( + model: &Self::Model, + delta: WheelDelta, + _cursor: (f32, f32), + _modifiers: Modifiers, + ) -> Option { + // `delta.y` viene en líneas (positivo = hacia abajo). El scroll + // del shell mide px desde el fondo, donde positivo = ver + // historial, así que invertimos y escalamos a ~40 px por línea. + let dpx = -delta.y * 40.0; + if dpx == 0.0 { + return None; + } + forward_wheel_to_focused_shell(model, dpx) + } + + fn update(model: Self::Model, msg: Self::Msg, handle: &Handle) -> Self::Model { + let mut m = model; + match msg { + Msg::Tick => { + m.last_snapshot = Some(m.sysmon.sample()); + sample_extra_monitors(&mut m); + } + Msg::ShellTick => { + drain_shell_instances(&mut m); + } + Msg::WawaConfigChanged(cfg) => { + // Re-armar el theme con el nuevo variant + accent. El + // fallback es el theme actual — si la nueva config tiene + // un variant raro, conservamos lo de antes. + m.theme = wawa_config_llimphi::theme_from_wawa(&cfg, &m.theme); + // Locale activo — `set_locale` es no-op si el lang no + // está en el catálogo; los próximos `t(...)` ya devuelven + // strings en el nuevo idioma sin necesidad de reiniciar + // (los labels in-memory siguen siendo viejos hasta que + // el módulo correspondiente vuelva a rehidratarlos, + // pero todo lo que se calcula en cada `view()` se + // refresca al instante). + let _ = rimay_localize::set_locale(&cfg.lang); + } + Msg::SelectTab(i) => { + if i < m.tabs.len() { + m.active_tab = i; + } + } + Msg::ResizeMonitors(dx) => { + m.monitors_width = (m.monitors_width - dx).clamp(180.0, 480.0); + } + Msg::Module(slot, mmsg) => { + // Hook: SelectRoot del módulo minga dispara la carga + // de la fuente reconstruida en un thread aparte. El + // mensaje se sigue propagando para que el state marque + // `selected = Some(alpha)` y `selected_source = None` + // mientras carga. + if let ModuleMsg::Minga(shuma_module_minga::Msg::SelectRoot(alpha)) = &mmsg { + if let Some(repo_path) = minga_repo_path(&slot, &m) { + let alpha = *alpha; + let slot_back = slot.clone(); + handle.spawn(move || { + let result = shuma_module_minga::load_root_source(&repo_path, alpha); + Msg::Module( + slot_back, + ModuleMsg::Minga(shuma_module_minga::Msg::SourceLoaded { + alpha, + result, + }), + ) + }); + } + } + m = apply_module_msg(m, slot, mmsg); + } + Msg::ShortcutClicked(slot, action) => { + m = handle_shortcut(m, slot, action, handle); + } + Msg::MenuOpen(idx) => { + m.menu_open = idx; + m.menu_active = usize::MAX; + // Abrir el menú principal cierra el contextual (y viceversa). + m.ctx_menu = None; + // Animación de aparición/swap: cada vez que se abre (o se + // cambia de) menú, el dropdown se funde+desliza de nuevo. + if idx.is_some() { + m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic); + animate(handle, motion::FAST, || Msg::MenuTick); + } + } + Msg::MenuNav(dir) => { + if let Some(mi) = m.menu_open { + let menu = menu::app_menu(&m); + m.menu_active = + llimphi_widget_menubar::menubar_nav(&menu, mi, m.menu_active, dir); + } + } + Msg::MenuActivate => { + if let Some(mi) = m.menu_open { + let menu = menu::app_menu(&m); + if let Some(cmd) = + llimphi_widget_menubar::menubar_command_at(&menu, mi, m.menu_active) + { + m = menu::handle_command(m, &cmd); + } + } + } + Msg::MenuTick => {} + Msg::ContextMenuOpen(x, y) => { + m.ctx_menu = Some((x, y)); + m.menu_open = None; + m.menu_active = usize::MAX; + } + Msg::CloseMenus => { + m.menu_open = None; + m.menu_active = usize::MAX; + m.ctx_menu = None; + } + Msg::MenuCommand(cmd) => { + m = menu::handle_command(m, &cmd); + } + Msg::HostActivate(id) => { + // Rail hospedado: un diente de tab selecciona esa tab; el + // diente sentinela togglea el panel de monitores. + if id == MONITORS_TOOTH { + m.monitors_visible = !m.monitors_visible; + } else if (id as usize) < m.tabs.len() { + m.active_tab = id as usize; + } + } + } + m + } + + fn view(model: &Self::Model) -> View { + let theme = &model.theme; + + let menubar = menu::menubar_row(model, theme); + let topbar = render_topbar(model, theme); + let main_area = render_main_area(model, theme); + let bottombar = render_bottombar(model, theme); + + // El right-click se engancha en la raíz (origen 0,0 → las coords + // locales que llegan al handler ya son de ventana) y abre el menú + // contextual de terminal. Un nodo hijo con su propio handler de + // right-click ganaría; hoy ninguno lo pone, así que la raíz es el + // catch-all. + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .on_right_click_at(|x, y, _w, _h| Some(Msg::ContextMenuOpen(x, y))) + .children(vec![menubar, topbar, main_area, bottombar]) + } + + fn view_overlay(model: &Self::Model) -> Option> { + menu::overlay(model) + } +} + +// Helpers partidos del monolito (regla dura #1, 1522 LOC): update + view. +mod menu; +mod update; +mod view; + +use update::*; +use view::*; diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/menu.rs b/02_ruway/shuma/shuma-shell-llimphi/src/menu.rs new file mode 100644 index 0000000..6cce688 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/menu.rs @@ -0,0 +1,369 @@ +//! Menú principal (barra) + menú contextual de terminal del chasis shuma. +//! +//! El input del shell es una línea de comando (`shuma_line::LineState`), +//! NO un `EditorState`/`TextInputState` estándar — por eso este menú no +//! usa el widget `edit-menu` (que necesita un `EditorState` con modelo de +//! selección). En su lugar arma a mano un menú contextual de terminal con +//! sólo las acciones que el módulo shell YA expone: pegar, limpiar la +//! entrada, limpiar la pantalla y cancelar el comando vivo. +//! +//! El menú principal (Archivo / Editar / Ver / Ayuda) mapea cada comando +//! al `Msg` real correspondiente del chasis o del módulo shell focado. + +use std::sync::Arc; + +use app_bus::{AppMenu, Menu, MenuItem}; +use llimphi_theme::Theme; +use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey, View}; +use llimphi_widget_context_menu::{ + context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec, +}; +use llimphi_widget_menubar::{ + menubar_overlay_animated, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H, +}; + +use super::{Model, Msg, ModuleMsg, ModuleState, Slot}; + +// ─── Estado del shell focado (lo que habilita/deshabilita el menú) ── + +/// Snapshot de lo que el menú necesita saber del shell que recibe las +/// teclas ahora mismo. `None` si la tab/slot activo no es un shell. +pub(crate) struct FocusInfo { + /// Slot al que enrutar las acciones del menú. + pub slot: Slot, + /// `true` si la línea de comando tiene texto (habilita "Limpiar entrada"). + pub has_input: bool, + /// `true` si hay un comando ejecutándose (habilita "Cancelar"). + pub running: bool, +} + +/// Encuentra el shell focado siguiendo la misma prioridad que +/// `forward_key_to_focused_shell`: slot `main` primero, luego el tab +/// activo. Devuelve `None` si ninguno de los dos es un shell. +pub(crate) fn focused_shell(model: &Model) -> Option { + let from = |slot: Slot, state: &ModuleState| match state { + ModuleState::Shell(s) => Some(FocusInfo { + slot, + has_input: !s.input.is_empty(), + running: s.is_running(), + }), + _ => None, + }; + if let Some(inst) = model.main.as_ref() { + if let Some(info) = from(Slot::Main, &inst.state) { + return Some(info); + } + } + if let Some(inst) = model.tabs.get(model.active_tab) { + if let Some(info) = from(Slot::Tab(model.active_tab), &inst.state) { + return Some(info); + } + } + None +} + +// ─── Menú principal ──────────────────────────────────────────────── + +/// Arma el `AppMenu` reflejando el estado real del shell focado: los +/// ítems de Editar se deshabilitan cuando la acción no aplica. +pub(crate) fn app_menu(model: &Model) -> AppMenu { + let focus = focused_shell(model); + let has_input = focus.as_ref().map(|f| f.has_input).unwrap_or(false); + let running = focus.as_ref().map(|f| f.running).unwrap_or(false); + let is_shell = focus.is_some(); + + // Alias para la función de localización. + let t = rimay_localize::t; + + // Archivo: lo único universal y honesto es salir del proceso. + let archivo = Menu::new(t("file")) + .item(MenuItem::new(t("exit"), "app.quit").shortcut("Ctrl+Q")); + + // Editar: opera sobre la línea de comando del shell focado. Sin + // copiar/cortar porque `LineState` no tiene modelo de selección. + let mut pegar = MenuItem::new(t("paste"), "edit.paste").shortcut("Ctrl+V"); + let mut limpiar_in = MenuItem::new(t("shuma-shell-clear-input"), "edit.clear-input"); + if !is_shell { + pegar = pegar.disabled(); + } + if !has_input { + limpiar_in = limpiar_in.disabled(); + } + let editar = Menu::new(t("edit")).item(pegar).item(limpiar_in); + + // Ver: limpiar pantalla + cancelar comando + selector de tabs. + let mut limpiar_pant = MenuItem::new(t("shuma-shell-clear-screen"), "term.clear"); + let mut cancelar = MenuItem::new(t("shuma-shell-cancel-cmd"), "term.cancel").shortcut("Ctrl+C"); + if !is_shell { + limpiar_pant = limpiar_pant.disabled(); + } + if !running { + cancelar = cancelar.disabled(); + } + let mut ver = Menu::new(t("view")).item(limpiar_pant).item(cancelar); + // Una entrada por tab para saltar directo (mapea a `Msg::SelectTab`). + for (i, inst) in model.tabs.iter().enumerate() { + let mut it = MenuItem::new(inst.label.clone(), format!("view.tab.{i}")); + if i == 0 { + it = it.separated(); + } + if i == model.active_tab { + it = it.disabled(); // ya estás acá + } + ver = ver.item(it); + } + + // Ayuda: imprime una línea "acerca de" en la entrada del shell + // focado (efecto visible y real; sin diálogos que el chasis no tiene). + let mut acerca = MenuItem::new(t("shuma-shell-about"), "help.about"); + if !is_shell { + acerca = acerca.disabled(); + } + let ayuda = Menu::new(t("help")).item(acerca); + + // Menú de idioma: autónimos sin traducir (convención del SO). El item + // activo lleva ✔. El comando `lang.` lo resuelve `handle_command` + // → set_locale + persiste en wawa-config. + let cur = rimay_localize::current_locale(); + let lang_item = |label: &str, code: &str| { + let mut it = MenuItem::new(label, format!("lang.{code}")); + if cur == code { + it = it.icon("\u{2714}"); + } + it + }; + let idioma = Menu::new(t("language")) + .item(lang_item("Español", "es-PE")) + .item(lang_item("English", "en-US")) + .item(lang_item("Runasimi", "qu-PE")); + + AppMenu::new() + .menu(archivo) + .menu(editar) + .menu(ver) + .menu(ayuda) + .menu(idioma) +} + +/// `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`. +pub(crate) fn menubar_spec<'a>( + menu: &'a AppMenu, + model: &Model, + theme: &'a Theme, +) -> MenuBarSpec<'a, Msg> { + MenuBarSpec { + menu, + open: model.menu_open, + theme, + viewport: viewport(), + height: MENU_H, + on_open: Arc::new(Msg::MenuOpen), + on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())), + } +} + +/// La fila de títulos — primer hijo del column raíz de `view()`. +pub(crate) fn menubar_row(model: &Model, theme: &Theme) -> View { + let menu = app_menu(model); + menubar_view(&menubar_spec(&menu, model, theme)) +} + +// ─── Menú contextual de terminal (right-click) ───────────────────── + +/// Construye el overlay a mostrar: prioriza el menú contextual de +/// terminal; si no, el dropdown del menú principal abierto. +pub(crate) fn overlay(model: &Model) -> Option> { + if let Some((x, y)) = model.ctx_menu { + return Some(terminal_context_menu(model, x, y)); + } + let menu = app_menu(model); + menubar_overlay_animated( + &menubar_spec(&menu, model, &model.theme), + model.menu_active, + model.menu_anim.value(), + ) +} + +fn terminal_context_menu(model: &Model, x: f32, y: f32) -> View { + let focus = focused_shell(model); + let has_input = focus.as_ref().map(|f| f.has_input).unwrap_or(false); + let running = focus.as_ref().map(|f| f.running).unwrap_or(false); + let is_shell = focus.is_some(); + + // "Pegar" reusa la ruta Ctrl+V del módulo, que internamente decide + // si pega a la línea de comando o al PTY (cuando hay un TUI vt100). + let t = rimay_localize::t; + let mut pegar = ContextMenuItem::action(t("paste")).with_shortcut("Ctrl+V"); + let mut limpiar_in = ContextMenuItem::action(t("shuma-shell-clear-input")); + let mut limpiar_pant = ContextMenuItem::action(t("shuma-shell-clear-screen")); + let mut cancelar = ContextMenuItem::action(t("shuma-shell-cancel-cmd")).with_shortcut("Ctrl+C"); + + if !is_shell { + pegar = pegar.disabled(); + limpiar_pant = limpiar_pant.disabled(); + } + if !has_input { + limpiar_in = limpiar_in.disabled(); + } + if !running { + cancelar = cancelar.disabled(); + } + + // Orden de items — el índice es el que recibe `on_pick`. + let items = vec![ + pegar, // 0 + limpiar_in, // 1 + ContextMenuItem::separator(), // 2 + limpiar_pant, // 3 + cancelar, // 4 + ]; + + let on_pick: Arc Msg + Send + Sync> = Arc::new(|i: usize| { + let cmd = match i { + 0 => "edit.paste", + 1 => "edit.clear-input", + 3 => "term.clear", + 4 => "term.cancel", + _ => "noop", + }; + Msg::MenuCommand(cmd.to_string()) + }); + + context_menu_view(ContextMenuSpec { + anchor: (x, y), + viewport: viewport(), + header: Some(rimay_localize::t("terminal")), + items, + active: usize::MAX, + on_pick, + on_dismiss: Msg::CloseMenus, + palette: ContextMenuPalette::from_theme(&model.theme), + }) +} + +// ─── Ruteo de comandos del menú a Msg/acciones reales ────────────── + +/// Traduce el `command` string de un ítem de menú a una transición del +/// modelo. Devuelve el modelo modificado (cerrando antes los menús). +pub(crate) fn handle_command(mut model: Model, cmd: &str) -> Model { + model.menu_open = None; + model.menu_active = usize::MAX; + model.ctx_menu = None; + + // Cambio de idioma desde el menú "Idioma": aplica el locale en caliente + // y lo persiste en la capa de usuario de wawa-config. El watcher de la + // propia app (y el del resto) reentra con `WawaConfigChanged`, así el + // cambio se propaga a todas las apps abiertas y sobrevive reinicios. + if let Some(code) = cmd.strip_prefix("lang.") { + let _ = rimay_localize::set_locale(code); + let mut cfg = wawa_config::WawaConfig::load(); + cfg.lang = code.to_string(); + let _ = cfg.save(); + return model; + } + + // Selector de tab: "view.tab.". + if let Some(rest) = cmd.strip_prefix("view.tab.") { + if let Ok(i) = rest.parse::() { + if i < model.tabs.len() { + model.active_tab = i; + } + } + return model; + } + + match cmd { + "app.quit" => { + std::process::exit(0); + } + "edit.paste" => route_to_shell(model, shell_paste_key()), + "edit.clear-input" => { + if let Some(focus) = focused_shell(&model) { + clear_input(&mut model, &focus.slot); + } + model + } + "term.clear" => route_to_shell(model, ModuleMsg::Shell(shuma_module_shell::Msg::Clear)), + "term.cancel" => route_to_shell(model, ModuleMsg::Shell(shuma_module_shell::Msg::Cancel)), + "help.about" => { + let line = format!("# shuma — shell soberano · {} tabs", model.tabs.len()); + route_to_shell( + model, + ModuleMsg::Shell(shuma_module_shell::Msg::InsertAtCursor(line)), + ) + } + _ => model, + } +} + +/// Enruta un `ModuleMsg` al shell focado (si lo hay). No-op si el slot +/// activo no es un shell. +fn route_to_shell(model: Model, msg: ModuleMsg) -> Model { + match focused_shell(&model) { + Some(focus) => super::apply_module_msg(model, focus.slot, msg), + None => model, + } +} + +/// Vacía la línea de comando del shell en `slot` mutando su `LineState` +/// directamente (no hay un `Msg` de "limpiar entrada" en el módulo). +fn clear_input(model: &mut Model, slot: &Slot) { + let inst = match slot { + Slot::Main => model.main.as_mut(), + Slot::Tab(i) => model.tabs.get_mut(*i), + _ => None, + }; + if let Some(inst) = inst { + if let ModuleState::Shell(s) = &mut inst.state { + s.input.clear(); + } + } +} + +/// `KeyEvent` sintético Ctrl+V — reusa la ruta de paste que el módulo +/// shell ya implementa (clipboard → input, o clipboard → PTY si hay TUI). +fn shell_paste_key() -> ModuleMsg { + ModuleMsg::Shell(shuma_module_shell::Msg::Key(KeyEvent { + key: Key::Character("v".into()), + state: KeyState::Pressed, + text: None, + modifiers: Modifiers { + ctrl: true, + ..Modifiers::default() + }, + repeat: false, + })) +} + +// ─── Navegación por teclado del menú (Esc cierra) ────────────────── + +/// Si hay algún menú abierto, intercepta Esc para cerrarlo. Devuelve +/// `Some(Msg::CloseMenus)` para que `on_key` corte el reenvío al shell. +pub(crate) fn intercept_key(model: &Model, e: &KeyEvent) -> Option { + // Menú principal abierto: las flechas navegan. ←/→ cambian de menú + // raíz (con wrap), ↑/↓ mueven la fila activa, Enter ejecuta, Esc + // cierra. El context-menu de terminal queda mouse-only (sólo Esc). + if let Some(mi) = model.menu_open { + let n = app_menu(model).menus.len().max(1); + return match &e.key { + Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus), + Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))), + Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))), + Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)), + Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)), + Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate), + _ => None, + }; + } + if model.ctx_menu.is_some() && matches!(e.key, Key::Named(NamedKey::Escape)) { + return Some(Msg::CloseMenus); + } + None +} + +/// Viewport para clampear los menús — shuma no trackea el tamaño de la +/// ventana, así que usamos el tamaño inicial (igual que `nada`). +fn viewport() -> (f32, f32) { + let (w, h) = ::initial_size(); + (w as f32, h as f32) +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/update.rs b/02_ruway/shuma/shuma-shell-llimphi/src/update.rs new file mode 100644 index 0000000..db37efb --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/update.rs @@ -0,0 +1,666 @@ +//! Helpers de `update`: ruteo de mensajes, resolución de instancias, atajos. + +use super::*; + +/// Enruta un `ModuleMsg` al `update` del módulo correspondiente, y se +/// encarga de interceptar mensajes que el chasis quiera promocionar +/// (p. ej. el click en la command bar abre el drawer). +pub(crate) fn apply_module_msg(mut m: Model, slot: Slot, msg: ModuleMsg) -> Model { + // Hook: click en la command bar (que llega como `ToggleMode`) abre + // el drawer si está cerrado. Si ya está abierto, deja que el módulo + // togglee su modo libremente. + // Hook: el `shuma-module-canvas` pide insertar una referencia + // `%cN`/`%pN` en el input del shell. Buscamos la primera instancia + // `Shell` (en el mismo orden que `sync_canvas_from_primary_shell`) + // y le mandamos `InsertAtCursor`. Si la shell vive en una tab, + // la enfocamos. La variante NO se propaga al canvas — el canvas + // solo emite la intención. + if let ModuleMsg::Canvas(shuma_module_canvas::Msg::InsertRef(text)) = &msg { + if let Some(target) = first_shell_slot(&m) { + let insert_msg = + ModuleMsg::Shell(shuma_module_shell::Msg::InsertAtCursor(text.clone())); + if let Slot::Tab(i) = &target { + m.active_tab = *i; + } + return apply_module_msg(m, target, insert_msg); + } + // Sin shell activo: el pedido se descarta silencioso. + return m; + } + + match slot { + Slot::TopBar => { + if let Some(inst) = m.topbar.as_mut() { + route_to_instance(inst, msg); + } + } + Slot::BottomBar => { + if let Some(inst) = m.bottombar.as_mut() { + route_to_instance(inst, msg); + } + } + Slot::Main => { + if let Some(inst) = m.main.as_mut() { + route_to_instance(inst, msg); + } + } + Slot::Tab(idx) => { + if let Some(inst) = m.tabs.get_mut(idx) { + route_to_instance(inst, msg); + } + } + } + m +} + +/// Mapea una entrada genérica `SlotEntry` del shumarc a una `Instance`. +/// `None` si el `module` no matchea ningún `Kind` compilado — se +/// imprime warning en lugar de fallar para no romper el arranque. +pub(crate) fn resolve_slot(entry: Option<&config::SlotEntry>) -> Option { + let entry = entry?; + resolve_instance( + &entry.module, + entry.source.clone(), + entry.label.clone(), + entry.inventory.as_deref(), + ) +} + +pub(crate) fn resolve_tab(entry: &config::TabEntry) -> Option { + resolve_instance( + &entry.id, + entry.source.clone(), + entry.label.clone(), + entry.inventory.as_deref(), + ) +} + +pub(crate) fn resolve_instance( + id: &str, + source: Source, + label: Option, + inventory: Option<&std::path::Path>, +) -> Option { + let label = label.unwrap_or_else(|| source.label()); + match id { + shuma_module_launcher::ID => Some(Instance::launcher( + shuma_module_launcher::State::from_apps_dir(), + )), + shuma_module_commandbar::ID => Some(Instance::command_bar( + shuma_module_commandbar::State::default(), + )), + shuma_module_shell::ID => Some(Instance::shell(label, source)), + shuma_module_matilda::ID => { + Some(Instance::matilda_with_inventory(label, source, inventory)) + } + shuma_module_minga::ID => Some(Instance::minga(label, source)), + shuma_module_canvas::ID => Some(Instance::canvas(label)), + unknown => { + eprintln!("shuma: módulo desconocido «{unknown}» — se ignora"); + None + } + } +} + +/// Fallback al inventario de ejemplo cuando el path declarado falla +/// — replica el default de `State::new` sin perder el path para reloads. +pub(crate) fn example_inventory_fallback() -> matilda_core::Inventory { + shuma_module_matilda::example_inventory() +} + +/// Lee un inventario JSON desde un path. Errores van a stderr y la +/// función retorna `None` — el chasis cae al ejemplo en lugar de +/// fallar el arranque (mismo criterio que el config TOML malformado). +pub(crate) fn load_matilda_inventory(path: &std::path::Path) -> Option { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) => { + eprintln!( + "shuma: no se pudo leer inventario {} ({e}) — uso ejemplo", + path.display() + ); + return None; + } + }; + match serde_json::from_str::(&text) { + Ok(inv) => Some(inv), + Err(e) => { + eprintln!( + "shuma: inventario {} mal formado ({e}) — uso ejemplo", + path.display() + ); + None + } + } +} + +/// Recolecta las `ModuleContributions` de todas las instancias vivas. +/// Devuelve un `Vec<(Slot, ModuleContributions)>` para que el caller +/// sepa de qué módulo viene cada monitor/shortcut. +pub(crate) fn collect_contributions(model: &Model) -> Vec<(Slot, ModuleContributions)> { + let mut out: Vec<(Slot, ModuleContributions)> = Vec::new(); + + let push = |out: &mut Vec<(Slot, ModuleContributions)>, slot: Slot, inst: &Instance| { + let c = match &inst.state { + ModuleState::Launcher(s) => shuma_module_launcher::contributions(s), + ModuleState::CommandBar(s) => shuma_module_commandbar::contributions(s), + ModuleState::Shell(s) => shuma_module_shell::contributions(s), + ModuleState::Matilda(s) => shuma_module_matilda::contributions(s), + ModuleState::Minga(s) => shuma_module_minga::contributions(s), + ModuleState::Canvas(s) => shuma_module_canvas::contributions(s), + }; + out.push((slot, c)); + }; + + if let Some(inst) = &model.topbar { + push(&mut out, Slot::TopBar, inst); + } + if let Some(inst) = &model.bottombar { + push(&mut out, Slot::BottomBar, inst); + } + if let Some(inst) = &model.main { + push(&mut out, Slot::Main, inst); + } + for (i, inst) in model.tabs.iter().enumerate() { + push(&mut out, Slot::Tab(i), inst); + } + out +} + +/// Muestrea **todos** los monitores extra (los aporta cada módulo +/// activo) e inserta el último valor en su buffer del modelo. +/// Recorta cada buffer a `HISTORY` muestras. +pub(crate) fn sample_extra_monitors(m: &mut Model) { + let contribs = collect_contributions(m); + for (slot, c) in contribs { + for spec in &c.monitors { + let key = monitor_key(&slot, spec); + let sample = (spec.sampler)(); + let entry = m.extra_history.entry(key.clone()).or_default(); + entry.push(sample.value); + if entry.len() > HISTORY { + let excess = entry.len() - HISTORY; + entry.drain(0..excess); + } + m.extra_display.insert(key, sample.display); + } + } +} + +/// Aplica `Msg::Tick` a cada `Instance` de tipo `Shell` activa para que +/// drene la salida streamed de `shuma-exec`. Llamado a cadencia rápida +/// (`SHELL_TICK`) sin tocar el muestreo de sysmon (`TICK`). +/// +/// Después de drenar, sincroniza el `intent_graph` de la primera shell +/// encontrada hacia todas las instancias `Canvas` activas — el lienzo +/// de contexto refleja en tiempo real los `%cN`/`%pN` del shell. +pub(crate) fn drain_shell_instances(m: &mut Model) { + fn tick_one(inst: &mut Instance) { + if let ModuleState::Shell(s) = &mut inst.state { + *s = shuma_module_shell::update(s.clone(), shuma_module_shell::Msg::Tick); + } + } + if let Some(inst) = m.topbar.as_mut() { + tick_one(inst); + } + if let Some(inst) = m.bottombar.as_mut() { + tick_one(inst); + } + if let Some(inst) = m.main.as_mut() { + tick_one(inst); + } + for inst in m.tabs.iter_mut() { + tick_one(inst); + } + sync_canvas_from_primary_shell(m); +} + +/// Toma el `intent_graph` de la primera instancia `Shell` encontrada +/// (en orden: topbar, bottombar, main, drawer tabs) y lo empuja a cada +/// instancia `Canvas` activa vía `Msg::SyncGraph`. Si no hay shells, el +/// canvas mantiene lo último que tenía (incluyendo su grafo de demo). +pub(crate) fn sync_canvas_from_primary_shell(m: &mut Model) { + let snapshot = find_primary_shell_graph(m); + let Some(graph) = snapshot else { return }; + let sync_one = |inst: &mut Instance| { + if let ModuleState::Canvas(s) = &mut inst.state { + *s = shuma_module_canvas::update( + s.clone(), + shuma_module_canvas::Msg::SyncGraph(graph.clone()), + ); + } + }; + if let Some(inst) = m.topbar.as_mut() { + sync_one(inst); + } + if let Some(inst) = m.bottombar.as_mut() { + sync_one(inst); + } + if let Some(inst) = m.main.as_mut() { + sync_one(inst); + } + for inst in m.tabs.iter_mut() { + sync_one(inst); + } +} + +/// Slot del primer `Shell` activo siguiendo el mismo orden que +/// `find_primary_shell_graph`. Lo usa el hook de `Msg::Canvas(InsertRef)` +/// para encontrar a quién enrutarle el `InsertAtCursor`. +pub(crate) fn first_shell_slot(m: &Model) -> Option { + if matches!( + m.topbar.as_ref().map(|i| &i.state), + Some(ModuleState::Shell(_)) + ) { + return Some(Slot::TopBar); + } + if matches!( + m.bottombar.as_ref().map(|i| &i.state), + Some(ModuleState::Shell(_)) + ) { + return Some(Slot::BottomBar); + } + if matches!( + m.main.as_ref().map(|i| &i.state), + Some(ModuleState::Shell(_)) + ) { + return Some(Slot::Main); + } + m.tabs.iter().enumerate().find_map(|(i, inst)| { + if matches!(inst.state, ModuleState::Shell(_)) { + Some(Slot::Tab(i)) + } else { + None + } + }) +} + +pub(crate) fn find_primary_shell_graph(m: &Model) -> Option { + let pick = |inst: &Instance| match &inst.state { + ModuleState::Shell(s) => Some(s.intent_graph().clone()), + _ => None, + }; + if let Some(inst) = m.topbar.as_ref() { + if let Some(g) = pick(inst) { + return Some(g); + } + } + if let Some(inst) = m.bottombar.as_ref() { + if let Some(g) = pick(inst) { + return Some(g); + } + } + if let Some(inst) = m.main.as_ref() { + if let Some(g) = pick(inst) { + return Some(g); + } + } + for inst in &m.tabs { + if let Some(g) = pick(inst) { + return Some(g); + } + } + None +} + +pub(crate) fn monitor_key(slot: &Slot, spec: &MonitorSpec) -> String { + let slot_label = match slot { + Slot::TopBar => "topbar", + Slot::BottomBar => "bottombar", + Slot::Main => "main", + Slot::Tab(i) => return format!("tab:{i}/{}", spec.id), + }; + format!("{slot_label}/{}", spec.id) +} + +/// Resuelve un `ShortcutClicked` en una transición concreta del +/// modelo. Las tres variantes: +/// +/// - `Command(line)` — por ahora, sólo se loguea en el log de Matilda +/// si está disponible; la ejecución real va con la integración del +/// REPL. +/// - `FocusTab(target)` — busca una tab con `Kind::id() == target` y la +/// activa. +/// - `ModuleAction(action_id)` — dispatcha al módulo emisor vía su +/// `dispatch(action_id) -> Option`. +pub(crate) fn handle_shortcut( + mut m: Model, + slot: Slot, + action: ShortcutAction, + handle: &Handle, +) -> Model { + match action { + ShortcutAction::Command { line } => { + // Hack temporario: lo agregamos al log del primer matilda + // que encontremos para que el usuario vea feedback. + if let Some(inst) = m + .tabs + .iter_mut() + .find(|i| matches!(i.state, ModuleState::Matilda(_))) + { + if let ModuleState::Matilda(s) = &mut inst.state { + s.log.push(format!("? command: {line}")); + } + } + } + ShortcutAction::FocusTab { target } => { + if let Some(i) = m.tabs.iter().position(|inst| inst.kind.id() == target) { + m.active_tab = i; + } + } + ShortcutAction::ModuleAction { action_id } => { + // Reload del inventario: el path lo lleva el State del + // módulo (cargado por el chasis al construir la instancia + // desde el shumarc). Sirve para Local y Remote por igual. + if action_id == "matilda.reload" { + if let Some(path) = matilda_inventory_path(&slot, &m) { + let mmsg = match load_matilda_inventory(&path) { + Some(inv) => shuma_module_matilda::Msg::SetDesired(inv), + None => shuma_module_matilda::Msg::LogLine(format!( + "✘ reload: ver stderr ({})", + path.display() + )), + }; + return apply_module_msg(m, slot, ModuleMsg::Matilda(mmsg)); + } else { + return apply_module_msg( + m, + slot, + ModuleMsg::Matilda(shuma_module_matilda::Msg::LogLine( + "✘ sin inventory_path: agregá `inventory = …` al shumarc".into(), + )), + ); + } + } + // Hooks remotos: ciertas acciones de matilda necesitan + // SSH + tokio. Las delegamos a un thread (`Handle::spawn`) + // que al volver dispatcha un Msg con el resultado. + if let Some((source, desired)) = remote_matilda_inputs(&slot, &m) { + if action_id == "matilda.discover" { + m = apply_module_msg( + m, + slot.clone(), + ModuleMsg::Matilda(shuma_module_matilda::Msg::LogLine(format!( + "→ conectando a {} para discover…", + source.label() + ))), + ); + let slot_back = slot.clone(); + handle.spawn(move || { + let msg = + match shuma_module_matilda::discover_remote_blocking(&source, &desired) + { + Ok(inv) => shuma_module_matilda::Msg::SetCurrent(inv), + Err(e) => shuma_module_matilda::Msg::LogLine(format!( + "✘ discover remoto: {e}" + )), + }; + Msg::Module(slot_back, ModuleMsg::Matilda(msg)) + }); + return m; + } + if action_id == "matilda.dry_run" { + m = apply_module_msg( + m, + slot.clone(), + ModuleMsg::Matilda(shuma_module_matilda::Msg::LogLine(format!( + "→ dry-run remoto en {} (sin tocar nada)…", + source.label() + ))), + ); + let slot_back = slot.clone(); + handle.spawn(move || { + let msg = match shuma_module_matilda::dry_run_remote_blocking( + &source, &desired, + ) { + Ok(lines) => shuma_module_matilda::Msg::DryRunReport(lines), + Err(e) => { + shuma_module_matilda::Msg::LogLine(format!("✘ dry-run remoto: {e}")) + } + }; + Msg::Module(slot_back, ModuleMsg::Matilda(msg)) + }); + return m; + } + if action_id == "matilda.apply" { + m = apply_module_msg( + m, + slot.clone(), + ModuleMsg::Matilda(shuma_module_matilda::Msg::LogLine(format!( + "→ apply remoto en {} por SSH…", + source.label() + ))), + ); + let slot_back = slot.clone(); + handle.spawn(move || { + let msg = + match shuma_module_matilda::apply_remote_blocking(&source, &desired) { + Ok((lines, new_current)) => { + shuma_module_matilda::Msg::ApplyReport { lines, new_current } + } + Err(e) => shuma_module_matilda::Msg::LogLine(format!( + "✘ apply remoto: {e}" + )), + }; + Msg::Module(slot_back, ModuleMsg::Matilda(msg)) + }); + return m; + } + } + // Minga refresh: el módulo es "declarativo" en update (no + // toca sled) — el load real lo hacemos acá en un thread y + // reenviamos el snapshot como SnapshotReady. + if action_id == "minga.refresh" { + if let Some(repo_path) = minga_repo_path(&slot, &m) { + let slot_back = slot.clone(); + handle.spawn(move || { + let result = shuma_module_minga::load_snapshot(&repo_path); + Msg::Module( + slot_back, + ModuleMsg::Minga(shuma_module_minga::Msg::SnapshotReady(result)), + ) + }); + // Y también marcar el state como "refreshing". + return apply_module_msg( + m, + slot, + ModuleMsg::Minga(shuma_module_minga::Msg::Refresh), + ); + } + } + // Minga verify_all: recorre las raíces del snapshot y las + // verifica una por una en un thread. + if action_id == "minga.verify_all" { + if let (Some(repo_path), Some(alphas)) = + (minga_repo_path(&slot, &m), minga_visible_alphas(&slot, &m)) + { + let slot_back = slot.clone(); + handle.spawn(move || { + let results = shuma_module_minga::verify_all_blocking(&repo_path, &alphas); + Msg::Module( + slot_back, + ModuleMsg::Minga(shuma_module_minga::Msg::VerifyAllReady(results)), + ) + }); + return apply_module_msg( + m, + slot, + ModuleMsg::Minga(shuma_module_minga::Msg::VerifyAll), + ); + } + } + let msg = dispatch_to_module(&slot, &m, action_id); + if let Some(mmsg) = msg { + m = apply_module_msg(m, slot, mmsg); + } + } + } + m +} + +/// Path del repo Minga de un slot que aloje el módulo minga. +pub(crate) fn minga_repo_path(slot: &Slot, model: &Model) -> Option { + let inst = match slot { + Slot::TopBar => model.topbar.as_ref()?, + Slot::BottomBar => model.bottombar.as_ref()?, + Slot::Main => model.main.as_ref()?, + Slot::Tab(i) => model.tabs.get(*i)?, + }; + match &inst.state { + ModuleState::Minga(s) => Some(s.repo_path.clone()), + _ => None, + } +} + +/// Lista de α-hashes de las raíces actualmente visibles en el snapshot +/// del módulo minga. `None` si el slot no es minga o no tiene snapshot +/// cargado todavía. +pub(crate) fn minga_visible_alphas( + slot: &Slot, + model: &Model, +) -> Option> { + let inst = match slot { + Slot::TopBar => model.topbar.as_ref()?, + Slot::BottomBar => model.bottombar.as_ref()?, + Slot::Main => model.main.as_ref()?, + Slot::Tab(i) => model.tabs.get(*i)?, + }; + match &inst.state { + ModuleState::Minga(s) => s + .snapshot + .as_ref() + .map(|snap| snap.recent.iter().map(|r| r.alpha).collect()), + _ => None, + } +} + +/// Si la tab activa (o el slot Main, si lo hay) es un shell, genera el +/// `Msg::Module` que reenvía la tecla. El módulo shell distingue Enter +/// (submit) de inserción de texto internamente. +/// Rutea la rueda del mouse al shell focado (mismo orden de prioridad +/// que las teclas). `dpx` ya viene en px (positivo = ver historial). +pub(crate) fn forward_wheel_to_focused_shell(model: &Model, dpx: f32) -> Option { + if let Some(inst) = model.main.as_ref() { + if matches!(inst.state, ModuleState::Shell(_)) { + return Some(Msg::Module( + Slot::Main, + ModuleMsg::Shell(shuma_module_shell::Msg::Scroll(dpx)), + )); + } + } + if let Some(inst) = model.tabs.get(model.active_tab) { + if matches!(inst.state, ModuleState::Shell(_)) { + return Some(Msg::Module( + Slot::Tab(model.active_tab), + ModuleMsg::Shell(shuma_module_shell::Msg::Scroll(dpx)), + )); + } + } + None +} + +pub(crate) fn forward_key_to_focused_shell(model: &Model, e: &KeyEvent) -> Option { + // 1) Slot Main siempre gana — si está configurado como shell, las + // teclas van ahí. Permite al usuario poner el shell como módulo + // principal de la ventana. + if let Some(inst) = model.main.as_ref() { + if matches!(inst.state, ModuleState::Shell(_)) { + return Some(Msg::Module( + Slot::Main, + ModuleMsg::Shell(shuma_module_shell::Msg::Key(e.clone())), + )); + } + } + // 2) Tab activo, si es un shell. + if let Some(inst) = model.tabs.get(model.active_tab) { + if matches!(inst.state, ModuleState::Shell(_)) { + return Some(Msg::Module( + Slot::Tab(model.active_tab), + ModuleMsg::Shell(shuma_module_shell::Msg::Key(e.clone())), + )); + } + } + None +} + +/// Path del inventario JSON de un slot de matilda, si lo tiene cargado. +pub(crate) fn matilda_inventory_path(slot: &Slot, model: &Model) -> Option { + let inst = match slot { + Slot::TopBar => model.topbar.as_ref()?, + Slot::BottomBar => model.bottombar.as_ref()?, + Slot::Main => model.main.as_ref()?, + Slot::Tab(i) => model.tabs.get(*i)?, + }; + let state = match &inst.state { + ModuleState::Matilda(s) => s.as_ref(), + _ => return None, + }; + state.inventory_path.clone() +} + +/// Si `slot` contiene una instancia de `matilda` y su `source` es +/// `Remote`, retorna `(source, desired)` clonados para que el thread +/// SSH los consuma sin tomar prestado del modelo. +pub(crate) fn remote_matilda_inputs( + slot: &Slot, + model: &Model, +) -> Option<(Source, matilda_core::Inventory)> { + let inst = match slot { + Slot::TopBar => model.topbar.as_ref()?, + Slot::BottomBar => model.bottombar.as_ref()?, + Slot::Main => model.main.as_ref()?, + Slot::Tab(i) => model.tabs.get(*i)?, + }; + let state = match &inst.state { + ModuleState::Matilda(s) => s.as_ref(), + _ => return None, + }; + if state.source.is_remote() { + Some((state.source.clone(), state.desired.clone())) + } else { + None + } +} + +pub(crate) fn dispatch_to_module(slot: &Slot, model: &Model, action_id: &str) -> Option { + let inst = match slot { + Slot::TopBar => model.topbar.as_ref()?, + Slot::BottomBar => model.bottombar.as_ref()?, + Slot::Main => model.main.as_ref()?, + Slot::Tab(i) => model.tabs.get(*i)?, + }; + match inst.kind { + Kind::Launcher => shuma_module_launcher::dispatch(action_id).map(ModuleMsg::Launcher), + Kind::CommandBar => shuma_module_commandbar::dispatch(action_id).map(ModuleMsg::CommandBar), + Kind::Shell => shuma_module_shell::dispatch(action_id).map(ModuleMsg::Shell), + Kind::Matilda => shuma_module_matilda::dispatch(action_id).map(ModuleMsg::Matilda), + Kind::Minga => shuma_module_minga::dispatch(action_id).map(ModuleMsg::Minga), + Kind::Canvas => shuma_module_canvas::dispatch(action_id).map(ModuleMsg::Canvas), + } +} + +pub(crate) fn route_to_instance(inst: &mut Instance, msg: ModuleMsg) { + match (&mut inst.state, msg) { + (ModuleState::Launcher(s), ModuleMsg::Launcher(m)) => { + *s = shuma_module_launcher::update(s.clone(), m); + } + (ModuleState::CommandBar(s), ModuleMsg::CommandBar(m)) => { + *s = shuma_module_commandbar::update(s.clone(), m); + } + (ModuleState::Shell(s), ModuleMsg::Shell(m)) => { + *s = shuma_module_shell::update(s.clone(), m); + } + (ModuleState::Matilda(s), ModuleMsg::Matilda(m)) => { + **s = shuma_module_matilda::update((**s).clone(), m); + } + (ModuleState::Minga(s), ModuleMsg::Minga(m)) => { + *s = shuma_module_minga::update(s.clone(), m); + } + (ModuleState::Canvas(s), ModuleMsg::Canvas(m)) => { + *s = shuma_module_canvas::update(s.clone(), m); + } + // Combinación inconsistente (state ≠ msg kind): no hace nada. + // El registry no debería emitirlos; si pasa es un bug del chasis. + _ => {} + } +} diff --git a/02_ruway/shuma/shuma-shell-llimphi/src/view.rs b/02_ruway/shuma/shuma-shell-llimphi/src/view.rs new file mode 100644 index 0000000..fb25c30 --- /dev/null +++ b/02_ruway/shuma/shuma-shell-llimphi/src/view.rs @@ -0,0 +1,447 @@ +//! Render del chasis: topbar, tabs, área principal, monitores. + +use super::*; + +// ─── Render de cada slot ──────────────────────────────────────────── + +pub(crate) fn render_topbar(model: &Model, theme: &Theme) -> View { + match &model.topbar { + Some(inst) => match (inst.kind, &inst.state) { + (Kind::Launcher, ModuleState::Launcher(state)) => { + shuma_module_launcher::view::(state, theme, |m| { + Msg::Module(Slot::TopBar, ModuleMsg::Launcher(m)) + }) + } + _ => empty_bar(theme, 40.0), + }, + None => empty_bar(theme, 40.0), + } +} + +pub(crate) fn render_bottombar(model: &Model, theme: &Theme) -> View { + match &model.bottombar { + Some(inst) => match (inst.kind, &inst.state) { + (Kind::CommandBar, ModuleState::CommandBar(state)) => { + shuma_module_commandbar::view::(state, theme, |m| { + Msg::Module(Slot::BottomBar, ModuleMsg::CommandBar(m)) + }) + } + _ => empty_bar(theme, 28.0), + }, + None => empty_bar(theme, 28.0), + } +} + +pub(crate) fn empty_bar(theme: &Theme, height: f32) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(height), + }, + ..Default::default() + }) + .fill(theme.bg_panel) +} + +/// Área central. Si el shumarc declara `[main]`, ese módulo ocupa todo +/// el espacio (sin tabs ni monitores). Si no, se renderizan las tabs + +/// monitor stack a la derecha vía splitter. +pub(crate) fn render_main_area(model: &Model, theme: &Theme) -> View { + let body = match &model.main { + Some(inst) => render_main_full(inst, theme), + None => render_tabs_with_monitors(model, theme), + }; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + ..Default::default() + }) + .fill(theme.bg_app) + .children(vec![body]) +} + +/// Render full-bleed del slot `main` cuando el shumarc lo configura. +/// Sin tabs ni monitores — útil para wrappers de una sola app. +pub(crate) fn render_main_full(inst: &Instance, theme: &Theme) -> View { + match (inst.kind, &inst.state) { + (Kind::Shell, ModuleState::Shell(state)) => shuma_module_shell::view::( + state, + theme, + |m| Msg::Module(Slot::Main, ModuleMsg::Shell(m)), + ), + (Kind::Matilda, ModuleState::Matilda(state)) => { + shuma_module_matilda::view::(state.as_ref(), theme, |m| { + Msg::Module(Slot::Main, ModuleMsg::Matilda(m)) + }) + } + (Kind::Minga, ModuleState::Minga(state)) => { + shuma_module_minga::view::(state, theme, |m| { + Msg::Module(Slot::Main, ModuleMsg::Minga(m)) + }) + } + (Kind::Canvas, ModuleState::Canvas(state)) => { + shuma_module_canvas::view::(state, theme, |m| { + Msg::Module(Slot::Main, ModuleMsg::Canvas(m)) + }) + } + _ => placeholder(theme, &rimay_localize::t("shuma-empty-main-incompat")), + } +} + +/// Layout normal: tira de tabs arriba con toolbar de shortcuts del +/// tab activo, splitter horizontal con (contenido | monitores). +pub(crate) fn render_tabs_with_monitors(model: &Model, theme: &Theme) -> View { + let tabs_palette = TabsPalette::from_theme(theme); + let splitter_palette = SplitterPalette::from_theme(theme); + + let toolbar = tabs_toolbar(model, theme); + let content = tab_content(model, theme); + + let labels: Vec = model.tabs.iter().map(|inst| inst.label.clone()).collect(); + + // El panel de monitores se oculta en modo delegado hasta que el rail de + // pata lo despliega (`monitors_visible`). Oculto → el contenido toma todo + // el ancho, sin splitter (puro lienzo). + let tab_body = if model.monitors_visible { + splitter_two( + Direction::Row, + content, + PaneSize::Flex, + monitor_stack(model, theme), + PaneSize::Fixed(model.monitors_width), + |phase, dx| match phase { + DragPhase::Move => Some(Msg::ResizeMonitors(dx)), + DragPhase::End => None, + }, + &splitter_palette, + ) + } else { + content + }; + + let tabs = tabs_view(TabsSpec { + labels, + active: model.active_tab, + on_select: Msg::SelectTab, + content: tab_body, + tab_height: 32.0, + palette: tabs_palette, + tab_width: None, + }); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + ..Default::default() + }) + .children(vec![toolbar, tabs]) +} + +/// Toolbar de la tira de tabs: pinta los `ShortcutSpec` del tab activo +/// como botones que disparan `Msg::ShortcutClicked`. Si el tab activo +/// no aporta shortcuts, la barra queda vacía (alto 0 — colapsa). +pub(crate) fn tabs_toolbar(model: &Model, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::prelude::Dimension; + use llimphi_ui::llimphi_text::Alignment; + + let Some(inst) = model.tabs.get(model.active_tab) else { + return empty_bar(theme, 0.0); + }; + let slot = Slot::Tab(model.active_tab); + let contribs = match &inst.state { + ModuleState::Launcher(s) => shuma_module_launcher::contributions(s), + ModuleState::CommandBar(s) => shuma_module_commandbar::contributions(s), + ModuleState::Shell(s) => shuma_module_shell::contributions(s), + ModuleState::Matilda(s) => shuma_module_matilda::contributions(s), + ModuleState::Minga(s) => shuma_module_minga::contributions(s), + ModuleState::Canvas(s) => shuma_module_canvas::contributions(s), + }; + + if contribs.shortcuts.is_empty() { + return empty_bar(theme, 0.0); + } + + let mut buttons: Vec> = contribs + .shortcuts + .into_iter() + .map(|spec| shortcut_button(slot.clone(), spec, theme)) + .collect(); + + // Label izquierdo: el nombre del tab activo. + let label = View::new(Style { + size: Size { + width: Dimension::auto(), + height: percent(1.0_f32), + }, + flex_grow: 1.0, + padding: Rect { + left: length(14.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(llimphi_ui::llimphi_layout::taffy::AlignItems::Center), + ..Default::default() + }) + .text_aligned(inst.label.clone(), 12.0, theme.fg_text, Alignment::Start); + + let mut row = vec![label]; + row.append(&mut buttons); + + View::new(Style { + flex_direction: FlexDirection::Row, + size: Size { + width: percent(1.0_f32), + height: length(34.0_f32), + }, + padding: Rect { + left: length(4.0_f32), + right: length(8.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(llimphi_ui::llimphi_layout::taffy::AlignItems::Center), + ..Default::default() + }) + .fill(theme.bg_panel) + .children(row) +} + +pub(crate) fn shortcut_button(slot: Slot, spec: ShortcutSpec, theme: &Theme) -> View { + use llimphi_ui::llimphi_layout::taffy::{prelude::Dimension, AlignItems, JustifyContent}; + use llimphi_ui::llimphi_text::Alignment; + + View::new(Style { + size: Size { + width: Dimension::auto(), + height: length(26.0_f32), + }, + padding: Rect { + left: length(12.0_f32), + right: length(12.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + margin: Rect { + left: length(4.0_f32), + right: length(0.0_f32), + top: length(0.0_f32), + bottom: length(0.0_f32), + }, + align_items: Some(AlignItems::Center), + justify_content: Some(JustifyContent::Center), + ..Default::default() + }) + .fill(theme.bg_button) + .hover_fill(theme.bg_button_hover) + .radius(4.0) + .text_aligned(spec.label.clone(), 12.0, theme.fg_text, Alignment::Center) + .on_click(Msg::ShortcutClicked(slot, spec.action)) +} + +pub(crate) fn tab_content(model: &Model, theme: &Theme) -> View { + let Some(inst) = model.tabs.get(model.active_tab) else { + return placeholder(theme, &rimay_localize::t("shuma-empty-no-tabs")); + }; + let idx = model.active_tab; + match (inst.kind, &inst.state) { + (Kind::Shell, ModuleState::Shell(state)) => { + shuma_module_shell::view::(state, theme, move |m| { + Msg::Module(Slot::Tab(idx), ModuleMsg::Shell(m)) + }) + } + (Kind::Matilda, ModuleState::Matilda(state)) => { + shuma_module_matilda::view::(state.as_ref(), theme, move |m| { + Msg::Module(Slot::Tab(idx), ModuleMsg::Matilda(m)) + }) + } + (Kind::Minga, ModuleState::Minga(state)) => { + shuma_module_minga::view::(state, theme, move |m| { + Msg::Module(Slot::Tab(idx), ModuleMsg::Minga(m)) + }) + } + (Kind::Canvas, ModuleState::Canvas(state)) => { + shuma_module_canvas::view::(state, theme, move |m| { + Msg::Module(Slot::Tab(idx), ModuleMsg::Canvas(m)) + }) + } + // Otros Kinds (Launcher/CommandBar) no tienen sentido como tab; + // mostramos un placeholder informativo. + _ => placeholder(theme, &rimay_localize::t("shuma-empty-no-tabs-compat")), + } +} + +// ─── Monitor stack ───────────────────────────────────────────────── + +pub(crate) fn monitor_stack(model: &Model, theme: &Theme) -> View { + let palette = StatCardPalette::from_theme(theme); + + let (cpu_value, mem_value) = match model.last_snapshot { + Some(s) if s.valid => (s.cpu_percent, s.mem_percent), + _ => (0.0, 0.0), + }; + + let cpu_card = monitor_card( + "CPU", + format!("{cpu_value:>3.0}%"), + match model.last_snapshot { + Some(s) if s.valid => format!( + "{} de {} muestras", + model.sysmon.cpu_history().len(), + HISTORY + ), + _ => rimay_localize::t("shuma-empty-no-data-linux"), + }, + Color::from_rgb8(0x82, 0xCF, 0xF2), + model.sysmon.cpu_history().values(), + &palette, + ); + + let mem_card = monitor_card( + "MEM", + format!("{mem_value:>3.0}%"), + match model.last_snapshot { + Some(s) if s.valid => format!("{} MB de {} MB", s.mem_used_mb, s.mem_total_mb), + _ => rimay_localize::t("shuma-empty-no-data"), + }, + Color::from_rgb8(0xF7, 0xC8, 0x7A), + model.sysmon.mem_history().values(), + &palette, + ); + + let mut children = vec![cpu_card, mem_card]; + + // Stat-cards extra: una por cada `MonitorSpec` aportado por los + // módulos vivos. El historial vive en `model.extra_history`. + for (slot, contribs) in collect_contributions(model) { + for spec in &contribs.monitors { + let key = monitor_key(&slot, spec); + let history = model + .extra_history + .get(&key) + .cloned() + .unwrap_or_default(); + let display = model + .extra_display + .get(&key) + .cloned() + .unwrap_or_else(|| "—".into()); + let accent = Color::from_rgb8(spec.accent.r, spec.accent.g, spec.accent.b); + children.push(monitor_card( + spec.label.as_str(), + display, + rimay_localize::t_args( + "shuma-stat-samples", + &[ + ("have", history.len().to_string().into()), + ("total", HISTORY.to_string().into()), + ], + ), + accent, + history, + &palette, + )); + } + } + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(10.0_f32), + right: length(10.0_f32), + top: length(10.0_f32), + bottom: length(10.0_f32), + }, + gap: Size { + width: length(0.0_f32), + height: length(10.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_panel_alt) + .children(children) +} + +pub(crate) fn monitor_card( + label: &str, + value: String, + description: String, + accent: Color, + history: Vec, + palette: &StatCardPalette, +) -> View { + let card = stat_card_view::(label, value, description.as_str(), accent, &[], palette); + let curve = curve_view(history, accent); + + View::new(Style { + flex_direction: FlexDirection::Column, + size: Size { + width: percent(1.0_f32), + height: Dimension::auto(), + }, + gap: Size { + width: length(0.0_f32), + height: length(6.0_f32), + }, + ..Default::default() + }) + .children(vec![card, curve]) +} + +pub(crate) fn curve_view(history: Vec, accent: Color) -> View { + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: length(56.0_f32), + }, + ..Default::default() + }) + .paint_with(move |scene, _ts, rect: PaintRect| { + if history.len() < 2 { + return; + } + let n = history.len() as f32; + let dx = if n > 1.0 { rect.w / (n - 1.0) } else { rect.w }; + let mut path = BezPath::new(); + for (i, v) in history.iter().enumerate() { + let x = rect.x + dx * i as f32; + let y = rect.y + rect.h - (v.clamp(0.0, 100.0) / 100.0) * rect.h; + let p = Point::new(x as f64, y as f64); + if i == 0 { + path.push(PathEl::MoveTo(p)); + } else { + path.push(PathEl::LineTo(p)); + } + } + scene.stroke(&Stroke::new(1.5), Affine::IDENTITY, accent, None, &path); + }) +} + +pub(crate) fn placeholder(theme: &Theme, text: &str) -> View { + use llimphi_ui::llimphi_text::Alignment; + View::new(Style { + size: Size { + width: percent(1.0_f32), + height: percent(1.0_f32), + }, + padding: Rect { + left: length(24.0_f32), + right: length(24.0_f32), + top: length(20.0_f32), + bottom: length(20.0_f32), + }, + ..Default::default() + }) + .fill(theme.bg_app) + .text_aligned(text.to_string(), 13.0, theme.fg_muted, Alignment::Start) +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e62b329 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,8601 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.12.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "app-bus" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "directories", + "serde", + "toml", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot 0.12.5", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arje-incarnate" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "anyhow", + "card-core", + "libc", + "nix 0.29.0", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.12.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "card-core" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "toml", + "ulid", +] + +[[package]] +name = "card-handshake" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "blake3", + "card-core", + "card-net", + "chasqui-broker", + "futures", + "notify", + "postcard", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "ulid", +] + +[[package]] +name = "card-net" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "blake3", + "futures", + "libp2p", + "libp2p-allow-block-list", + "libp2p-stream", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "card-sidecar" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "card-core", + "card-handshake", + "card-net", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chasqui-broker" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "card-core", + "serde", + "thiserror 2.0.18", + "ulid", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width 0.1.14", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "font-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-cache-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18" +dependencies = [ + "bytemuck", + "thiserror 1.0.69", +] + +[[package]] +name = "fontique" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7" +dependencies = [ + "bytemuck", + "fontconfig-cache-parser", + "hashbrown 0.15.5", + "icu_locid", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "peniko", + "read-fonts 0.29.3", + "roxmltree", + "smallvec", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "fuser" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" +dependencies = [ + "libc", + "log", + "memchr", + "nix 0.29.0", + "page_size", + "smallvec", + "zerocopy", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-bounded" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f328e7fb845fc832912fb6a34f40cf6d1888c92f974d1893a54e97b5ff542e" +dependencies = [ + "futures-timer", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot 0.12.5", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.12.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.12.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "socket2 0.5.10", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot 0.12.5", + "rand 0.9.4", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.4", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap 0.8.2", + "tinystr 0.8.3", + "writeable 0.6.3", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap 0.7.5", + "tinystr 0.7.6", + "writeable 0.5.5", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable 0.6.3", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "if-watch" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" +dependencies = [ + "async-io", + "core-foundation", + "fnv", + "futures", + "if-addrs", + "ipnet", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "rtnetlink", + "system-configuration", + "tokio", + "windows 0.62.2", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.4", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "internal-russh-forked-ssh-key" +version = "0.6.11+upstream-0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a77eae781ed6a7709fb15b64862fcca13d886b07c7e2786f5ed34e5e2b9187" +dependencies = [ + "argon2", + "bcrypt-pbkdf", + "ecdsa", + "ed25519-dalek", + "hex", + "hmac", + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.4", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.12.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libp2p" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce71348bf5838e46449ae240631117b487073d5f347c06d434caddcb91dceb5a" +dependencies = [ + "bytes", + "either", + "futures", + "futures-timer", + "getrandom 0.2.17", + "libp2p-allow-block-list", + "libp2p-autonat", + "libp2p-connection-limits", + "libp2p-core", + "libp2p-dcutr", + "libp2p-dns", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-mdns", + "libp2p-metrics", + "libp2p-noise", + "libp2p-quic", + "libp2p-relay", + "libp2p-swarm", + "libp2p-tcp", + "libp2p-upnp", + "libp2p-yamux", + "multiaddr", + "pin-project", + "rw-stream-sink", + "thiserror 2.0.18", +] + +[[package]] +name = "libp2p-allow-block-list" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16ccf824ee859ca83df301e1c0205270206223fd4b1f2e512a693e1912a8f4a" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-autonat" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab5e25c49a7d48dac83d95d8f3bac0a290d8a5df717012f6e34ce9886396c0b" +dependencies = [ + "async-trait", + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-request-response", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "rand_core 0.6.4", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-connection-limits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18b8b607cf3bfa2f8c57db9c7d8569a315d5cc0a282e6bfd5ebfc0a9840b2a0" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-core" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249128cd37a2199aff30a7675dffa51caf073b51aa612d2f544b19932b9aebca" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "libp2p-identity", + "multiaddr", + "multihash", + "multistream-select", + "parking_lot 0.12.5", + "pin-project", + "quick-protobuf", + "rand 0.8.6", + "rw-stream-sink", + "thiserror 2.0.18", + "tracing", + "unsigned-varint 0.8.0", + "web-time", +] + +[[package]] +name = "libp2p-dcutr" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4107305e12158af3e66960b6181789c547394c9c9a8696f721521602bfc73a" +dependencies = [ + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "hashlink", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-dns" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b770c1c8476736ca98c578cba4b505104ff8e842c2876b528925f9766379f9a" +dependencies = [ + "async-trait", + "futures", + "hickory-resolver", + "libp2p-core", + "libp2p-identity", + "parking_lot 0.12.5", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-identify" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab792a8b68fdef443a62155b01970c81c3aadab5e659621b063ef252a8e65e8" +dependencies = [ + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "libp2p-identity" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c" +dependencies = [ + "bs58", + "ed25519-dalek", + "hkdf", + "multihash", + "prost", + "rand 0.8.6", + "sha2", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "libp2p-kad" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d3fd632a5872ec804d37e7413ceea20588f69d027a0fa3c46f82574f4dee60" +dependencies = [ + "asynchronous-codec", + "bytes", + "either", + "fnv", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tracing", + "uint", + "web-time", +] + +[[package]] +name = "libp2p-mdns" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66872d0f1ffcded2788683f76931be1c52e27f343edb93bc6d0bcd8887be443" +dependencies = [ + "futures", + "hickory-proto", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "smallvec", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-metrics" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805a555148522cb3414493a5153451910cb1a146c53ffbf4385708349baf62b7" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-dcutr", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-relay", + "libp2p-swarm", + "pin-project", + "prometheus-client", + "web-time", +] + +[[package]] +name = "libp2p-noise" +version = "0.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc73eacbe6462a0eb92a6527cac6e63f02026e5407f8831bde8293f19217bfbf" +dependencies = [ + "asynchronous-codec", + "bytes", + "futures", + "libp2p-core", + "libp2p-identity", + "multiaddr", + "multihash", + "quick-protobuf", + "rand 0.8.6", + "snow", + "static_assertions", + "thiserror 2.0.18", + "tracing", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "libp2p-quic" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc448b2de9f4745784e3751fe8bc6c473d01b8317edd5ababcb0dec803d843f" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-tls", + "quinn", + "rand 0.8.6", + "ring", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-relay" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9b0392ed623243ad298326b9f806d51191829ac7585cc825c54c6c67b04d9" +dependencies = [ + "asynchronous-codec", + "bytes", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.6", + "static_assertions", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-request-response" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f1cca83488b90102abac7b67d5c36fc65bc02ed47620228af7ed002e6a1478" +dependencies = [ + "async-trait", + "futures", + "futures-bounded", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-stream" +version = "0.4.0-alpha" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6bd8025c80205ec2810cfb28b02f362ab48a01bee32c50ab5f12761e033464" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.6", + "tracing", +] + +[[package]] +name = "libp2p-swarm" +version = "0.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce88c6c4bf746c8482480345ea3edfd08301f49e026889d1cbccfa1808a9ed9e" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "hashlink", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm-derive", + "multistream-select", + "rand 0.8.6", + "smallvec", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-swarm-derive" +version = "0.35.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c" +dependencies = [ + "heck", + "quote", + "syn", +] + +[[package]] +name = "libp2p-tcp" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb6585b9309699f58704ec9ab0bb102eca7a3777170fa91a8678d73ca9cafa93" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libc", + "libp2p-core", + "socket2 0.6.4", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-tls" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ff65a82e35375cbc31ebb99cacbbf28cb6c4fefe26bf13756ddcf708d40080" +dependencies = [ + "futures", + "futures-rustls", + "libp2p-core", + "libp2p-identity", + "rcgen", + "ring", + "rustls", + "rustls-webpki", + "thiserror 2.0.18", + "x509-parser", + "yasna", +] + +[[package]] +name = "libp2p-upnp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4757e65fe69399c1a243bbb90ec1ae5a2114b907467bf09f3575e899815bb8d3" +dependencies = [ + "futures", + "futures-timer", + "igd-next", + "libp2p-core", + "libp2p-swarm", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-yamux" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f15df094914eb4af272acf9adaa9e287baa269943f32ea348ba29cfb9bfc60d8" +dependencies = [ + "either", + "futures", + "libp2p-core", + "thiserror 2.0.18", + "tracing", + "yamux 0.12.1", + "yamux 0.13.10", +] + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.12.1", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "llimphi-compositor" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-layout", + "llimphi-text", + "vello", + "wgpu", +] + +[[package]] +name = "llimphi-hal" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "pollster", + "raw-window-handle", + "wgpu", + "winit", +] + +[[package]] +name = "llimphi-icons" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-ui", +] + +[[package]] +name = "llimphi-layout" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "taffy", +] + +[[package]] +name = "llimphi-motion" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-raster" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-hal", + "pollster", + "vello", +] + +[[package]] +name = "llimphi-text" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "parley", + "vello", +] + +[[package]] +name = "llimphi-theme" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-raster", +] + +[[package]] +name = "llimphi-ui" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-compositor", + "llimphi-hal", + "llimphi-layout", + "llimphi-raster", + "llimphi-text", + "pollster", +] + +[[package]] +name = "llimphi-widget-button" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-card" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-context-menu" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-menubar" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-context-menu", +] + +[[package]] +name = "llimphi-widget-panel" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-splitter" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-stat-card" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-card", +] + +[[package]] +name = "llimphi-widget-tabs" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-text-editor" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-editor-core", + "tree-sitter", +] + +[[package]] +name = "llimphi-widget-text-editor-core" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "peniko", + "ropey", + "tree-sitter", + "tree-sitter-python", + "tree-sitter-rust", +] + +[[package]] +name = "llimphi-widget-text-input" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-editor", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matilda" +version = "0.1.0" +dependencies = [ + "clap", + "matilda-apply", + "matilda-config", + "matilda-core", + "matilda-discover", + "matilda-ghost", + "matilda-linker", + "matilda-plan", + "serde_json", + "tokio", +] + +[[package]] +name = "matilda-apply" +version = "0.1.0" +dependencies = [ + "matilda-config", + "matilda-core", + "matilda-plan", + "serde", +] + +[[package]] +name = "matilda-config" +version = "0.1.0" +dependencies = [ + "matilda-core", +] + +[[package]] +name = "matilda-core" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "matilda-discover" +version = "0.1.0" +dependencies = [ + "matilda-core", + "matilda-plan", + "serde", + "serde_json", +] + +[[package]] +name = "matilda-ghost" +version = "0.1.0" +dependencies = [ + "matilda-apply", + "serde", +] + +[[package]] +name = "matilda-linker" +version = "0.1.0" +dependencies = [ + "matilda-apply", + "matilda-ghost", + "ssh", +] + +[[package]] +name = "matilda-plan" +version = "0.1.0" +dependencies = [ + "matilda-core", + "serde", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.12.1", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minga-core" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "aes-gcm", + "argon2", + "blake3", + "ed25519-dalek", + "rand 0.8.6", + "serde", + "serde-big-array", + "thiserror 2.0.18", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", +] + +[[package]] +name = "minga-store" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "minga-core", + "postcard", + "serde", + "sled", + "thiserror 2.0.18", +] + +[[package]] +name = "minga-vfs" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "fuser", + "libc", + "minga-core", + "minga-store", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multiaddr" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6351f60b488e04c1d21bc69e56b89cb3f5e8f5d22557d6e8031bdfd79b6961" +dependencies = [ + "arrayref", + "byteorder", + "data-encoding", + "libp2p-identity", + "multibase", + "multihash", + "percent-encoding", + "serde", + "static_assertions", + "unsigned-varint 0.8.0", + "url", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "unsigned-varint 0.8.0", +] + +[[package]] +name = "multistream-select" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0df8e5eec2298a62b326ee4f0d7fe1a6b90a09dfcf9df37b38f947a8c42f19" +dependencies = [ + "bytes", + "futures", + "log", + "pin-project", + "smallvec", + "unsigned-varint 0.7.2", +] + +[[package]] +name = "naga" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.12.1", + "cfg_aliases 0.2.1", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "rustc-hash 1.1.0", + "spirv", + "strum", + "termcolor", + "thiserror 2.0.18", + "unicode-xid", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.12.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags 2.12.1", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.12.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.8.6", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.12.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.12.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.12.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.12.1", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.12.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.12.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.12.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pageant" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb28bd89a207e5cad59072ac4b364b08459d05f90ccfbcdaa920a95857d94430" +dependencies = [ + "byteorder", + "bytes", + "delegate", + "futures", + "log", + "rand 0.8.6", + "thiserror 1.0.69", + "tokio", + "windows 0.59.0", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parley" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28dadbe655332fd7d996794ec8d0c376695f6ca47bc75aa01e0967c7f28e42a" +dependencies = [ + "fontique", + "hashbrown 0.15.5", + "peniko", + "skrifa 0.31.3", + "swash", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pata-host" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "postcard", + "serde", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "peniko" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288" +dependencies = [ + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pineal-core" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" + +[[package]] +name = "pineal-render" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-ui", + "pineal-core", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "pkcs5", + "rand_core 0.6.4", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.12.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "prometheus-client" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf41c1a7c32ed72abe5082fb19505b969095c12da9f5732a4bc9878757fd087c" +dependencies = [ + "dtoa", + "itoa", + "parking_lot 0.12.5", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-protobuf" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6da84cc204722a989e01ba2f6e1e276e190f22263d0cb6ce8526fcdb0d2e1f" +dependencies = [ + "byteorder", +] + +[[package]] +name = "quick-protobuf-codec" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a0580ab32b169745d7a39db2ba969226ca16738931be152a3209b409de2474" +dependencies = [ + "asynchronous-codec", + "bytes", + "quick-protobuf", + "thiserror 1.0.69", + "unsigned-varint 0.8.0", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "futures-io", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2 0.6.4", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "read-fonts" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ea612a55c08586a1d15134be8a776186c440c312ebda3b9e8efbfe4255b7f4" +dependencies = [ + "bytemuck", + "font-types 0.9.0", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rimay-localize" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "fluent-bundle", + "once_cell", + "parking_lot 0.12.5", + "sys-locale", + "thiserror 2.0.18", + "tracing", + "unic-langid", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "russh" +version = "0.54.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3ee9363fcf66d434d8015d9ae7d879681206981534c21bfdff8a7e34f52cca" +dependencies = [ + "aes", + "aws-lc-rs", + "base64ct", + "bitflags 2.12.1", + "block-padding", + "byteorder", + "bytes", + "cbc", + "ctr", + "curve25519-dalek", + "data-encoding", + "delegate", + "der", + "digest", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "enum_dispatch", + "flate2", + "futures", + "generic-array", + "getrandom 0.2.17", + "hex-literal", + "hmac", + "home", + "inout", + "internal-russh-forked-ssh-key", + "log", + "md5", + "num-bigint", + "once_cell", + "p256", + "p384", + "p521", + "pageant", + "pbkdf2", + "pkcs1", + "pkcs5", + "pkcs8", + "rand 0.8.6", + "rand_core 0.6.4", + "rsa", + "russh-cryptovec", + "russh-util", + "sec1", + "sha1", + "sha2", + "signature", + "spki", + "ssh-encoding", + "subtle", + "thiserror 1.0.69", + "tokio", + "typenum", + "zeroize", +] + +[[package]] +name = "russh-cryptovec" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb0ed583ff0f6b4aa44c7867dd7108df01b30571ee9423e250b4cc939f8c6cf" +dependencies = [ + "libc", + "log", + "nix 0.29.0", + "ssh-encoding", + "winapi", +] + +[[package]] +name = "russh-util" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6" +dependencies = [ + "chrono", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.12.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rw-stream-sink" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c9026ff5d2f23da5e45bbc283f156383001bfb09c4e44256d02c1a685fe9a1" +dependencies = [ + "futures", + "pin-project", + "static_assertions", +] + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sandokan-core" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "async-trait", + "card-core", + "sandokan-lifecycle", + "serde", + "thiserror 2.0.18", + "ulid", +] + +[[package]] +name = "sandokan-lifecycle" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "serde", +] + +[[package]] +name = "sandokan-local" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "arje-incarnate", + "async-trait", + "card-core", + "libc", + "nix 0.29.0", + "sandokan-core", + "sandokan-lifecycle", + "serde", + "serde_json", + "tokio", + "ulid", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "shuma-card" +version = "0.1.0" +dependencies = [ + "card-core", + "sandokan-core", + "sandokan-local", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "toml", + "ulid", +] + +[[package]] +name = "shuma-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "card-core", + "clap", + "serde_json", + "shuma-card", + "shuma-protocol", + "tokio", + "ulid", +] + +[[package]] +name = "shuma-config" +version = "0.1.0" +dependencies = [ + "directories", + "serde", + "tempfile", + "toml", +] + +[[package]] +name = "shuma-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "arje-incarnate", + "card-core", + "libc", + "nix 0.29.0", + "serde", + "serde_json", + "shuma-card", + "shuma-discern", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "ulid", +] + +[[package]] +name = "shuma-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "arje-incarnate", + "card-core", + "card-sidecar", + "libc", + "nix 0.29.0", + "shuma-card", + "shuma-core", + "shuma-discern", + "shuma-exec", + "shuma-link", + "shuma-protocol", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "ulid", +] + +[[package]] +name = "shuma-discern" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "card-core", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "shuma-exec" +version = "0.1.0" +dependencies = [ + "nix 0.29.0", + "portable-pty", +] + +[[package]] +name = "shuma-gateway" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde_json", + "shuma-protocol", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "shuma-history" +version = "0.1.0" +dependencies = [ + "directories", + "nucleo-matcher", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "shuma-infer" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "shuma-intent" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "shuma-line" +version = "0.1.0" +dependencies = [ + "serde", + "tempfile", +] + +[[package]] +name = "shuma-link" +version = "0.1.0" +dependencies = [ + "directories", + "hex", + "postcard", + "serde", + "snow", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "shuma-module" +version = "0.1.0" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "shuma-module-canvas" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "shuma-intent", + "shuma-module", +] + +[[package]] +name = "shuma-module-commandbar" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "nucleo-matcher", + "shuma-module", +] + +[[package]] +name = "shuma-module-launcher" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "serde", + "shuma-module", + "toml", +] + +[[package]] +name = "shuma-module-matilda" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-splitter", + "matilda-apply", + "matilda-core", + "matilda-discover", + "matilda-ghost", + "matilda-linker", + "matilda-plan", + "shuma-module", + "ssh", + "tokio", +] + +[[package]] +name = "shuma-module-minga" +version = "0.1.0" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "minga-core", + "minga-store", + "minga-vfs", + "shuma-module", + "tempfile", +] + +[[package]] +name = "shuma-module-shell" +version = "0.1.0" +dependencies = [ + "arboard", + "llimphi-icons", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-input", + "png 0.18.1", + "pollster", + "shuma-exec", + "shuma-history", + "shuma-infer", + "shuma-intent", + "shuma-line", + "shuma-link", + "shuma-module", + "shuma-protocol", + "shuma-remote-exec", + "vt100", +] + +[[package]] +name = "shuma-protocol" +version = "0.1.0" +dependencies = [ + "card-core", + "nix 0.29.0", + "postcard", + "serde", + "shuma-card", + "thiserror 2.0.18", + "tokio", + "ulid", +] + +[[package]] +name = "shuma-remote-exec" +version = "0.1.0" +dependencies = [ + "shuma-exec", + "shuma-link", + "shuma-protocol", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "shuma-session" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "shuma-shell-llimphi" +version = "0.1.0" +dependencies = [ + "app-bus", + "directories", + "llimphi-motion", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-menubar", + "llimphi-widget-splitter", + "llimphi-widget-stat-card", + "llimphi-widget-tabs", + "matilda-core", + "minga-core", + "pata-host", + "rimay-localize", + "serde", + "serde_json", + "shuma-intent", + "shuma-module", + "shuma-module-canvas", + "shuma-module-commandbar", + "shuma-module-launcher", + "shuma-module-matilda", + "shuma-module-minga", + "shuma-module-shell", + "shuma-sysmon", + "tempfile", + "toml", + "wawa-config", + "wawa-config-llimphi", +] + +[[package]] +name = "shuma-shell-render" +version = "0.1.0" +dependencies = [ + "pineal-render", + "shuma-intent", +] + +[[package]] +name = "shuma-sysmon" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607" +dependencies = [ + "bytemuck", + "read-fonts 0.29.3", +] + +[[package]] +name = "skrifa" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576e60c7de4bb6a803a0312f9bef17e78cf1e8d25a80e1ade76770d7a0237955" +dependencies = [ + "bytemuck", + "read-fonts 0.33.1", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sled" +version = "0.34.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +dependencies = [ + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot 0.11.2", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.12.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "rand_core 0.6.4", + "ring", + "rustc_version", + "sha2", + "subtle", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "ssh" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "russh", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", + "cipher", + "ctr", + "poly1305", + "ssh-encoding", + "subtle", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "bytes", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +dependencies = [ + "skrifa 0.40.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.12.1", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio 1.2.1", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "serde", + "web-time", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr 0.8.3", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr 0.8.3", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsigned-varint" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vello" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa3f8a53870a2ee699ce05b738a3f9974c92c35ed4874de86052ac68d214811c" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png 0.17.16", + "skrifa 0.35.0", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c69b0fe94b0ac7e47619c504ee2c377355174f5c46353c46d03fa5f7e435922b" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa 0.35.0", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ebea426bb2f95b7610bca09178b03d809ede1d3c500a9acf6eca43e8f200be" +dependencies = [ + "bytemuck", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width 0.2.2", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.12.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wawa-config" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "directories", + "notify", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "wawa-config-llimphi" +version = "0.1.0" +source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55" +dependencies = [ + "llimphi-theme", + "wawa-config", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.12.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.12.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.12.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +dependencies = [ + "arrayvec", + "bitflags 2.12.1", + "cfg_aliases 0.2.1", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "24.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.12.1", + "cfg_aliases 0.2.1", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot 0.12.5", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "24.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.12.1", + "block", + "bytemuck", + "cfg_aliases 0.2.1", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "ordered-float", + "parking_lot 0.12.5", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags 2.12.1", + "js-sys", + "log", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f919aee0a93304be7f62e8e5027811bbba96bcb1de84d6618be56e43f8a32a1" +dependencies = [ + "windows-core 0.59.0", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "810ce18ed2112484b0d4e15d022e5f598113e220c53e373fb31e67e21670c1ce" +dependencies = [ + "windows-implement 0.59.0", + "windows-interface 0.59.3", + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.12.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases 0.2.1", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.12.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.12.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yamux" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed0164ae619f2dc144909a9f082187ebb5893693d8c0196e8085283ccd4b776" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot 0.12.5", + "pin-project", + "rand 0.8.6", + "static_assertions", +] + +[[package]] +name = "yamux" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot 0.12.5", + "pin-project", + "rand 0.9.4", + "static_assertions", + "web-time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7c6316e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,477 @@ +# Cargo.toml raíz STANDALONE de shuma — front-door sobre Llimphi. +# Solo el código de shuma; Llimphi y lo fundacional por git-dep del monorepo gioser.git. +[workspace] +resolver = "2" +members = [ + "02_ruway/shuma/baremetal/matilda-app", + "02_ruway/shuma/baremetal/matilda-apply", + "02_ruway/shuma/baremetal/matilda-config", + "02_ruway/shuma/baremetal/matilda-core", + "02_ruway/shuma/baremetal/matilda-discover", + "02_ruway/shuma/baremetal/matilda-ghost", + "02_ruway/shuma/baremetal/matilda-linker", + "02_ruway/shuma/baremetal/matilda-plan", + "02_ruway/shuma/sandbox/shuma-card", + "02_ruway/shuma/sandbox/shuma-config", + "02_ruway/shuma/sandbox/shuma-core", + "02_ruway/shuma/sandbox/shuma-exec", + "02_ruway/shuma/sandbox/shuma-history", + "02_ruway/shuma/sandbox/shuma-infer", + "02_ruway/shuma/sandbox/shuma-intent", + "02_ruway/shuma/sandbox/shuma-line", + "02_ruway/shuma/sandbox/shuma-link", + "02_ruway/shuma/sandbox/shuma-module", + "02_ruway/shuma/sandbox/shuma-module-canvas", + "02_ruway/shuma/sandbox/shuma-module-commandbar", + "02_ruway/shuma/sandbox/shuma-module-launcher", + "02_ruway/shuma/sandbox/shuma-module-matilda", + "02_ruway/shuma/sandbox/shuma-module-minga", + "02_ruway/shuma/sandbox/shuma-module-shell", + "02_ruway/shuma/sandbox/shuma-protocol", + "02_ruway/shuma/sandbox/shuma-remote-exec", + "02_ruway/shuma/sandbox/shuma-session", + "02_ruway/shuma/sandbox/shuma-shell-render", + "02_ruway/shuma/sandbox/shuma-sysmon", + "02_ruway/shuma/shuma-cli", + "02_ruway/shuma/shuma-daemon", + "02_ruway/shuma/shuma-gateway", + "02_ruway/shuma/shuma-shell-llimphi", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.80" +license = "MIT" +authors = ["Sergio "] +publish = false +repository = "https://gitea.gioser.net/sergio/shuma" + +[workspace.dependencies] + +# === Registro de apps / menú global === +app-bus = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# === Serialización === +serde = { version = "1", features = ["derive"] } +serde_json = "1" +lsp-types = "0.97" +serde-big-array = "0.5" +postcard = { version = "1", features = ["use-std"] } +toml = "0.8" +ron = "0.8" +bincode = "1" +base64 = "0.22" + +# === Errores === +thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores +anyhow = "1" + +# === Async === +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["compat"] } +async-trait = "0.1" +futures = "0.3" + +# === Observabilidad === +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +# === Linux primitives (arje) === +nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] } +libc = "0.2" + +# === IDs / Hash / Crypto === +ulid = { version = "1", features = ["serde"] } +uuid = { version = "1", features = ["v4", "rng-getrandom"] } +sha2 = "0.10" +blake3 = "1.5" +ed25519-dalek = "2" +aes-gcm = "0.10" +chacha20poly1305 = "0.10" +argon2 = "0.5" +rand = "0.8" + +# === WASM (arje) === +# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para +# que el ABI WASM del host sea idéntico en Linux y en bare-metal. +wasmi = "1.0" +wat = "1" + +# === Storage / DB === +sled = "0.34" +rusqlite = { version = "0.31", features = ["bundled", "blob"] } + +# === Ingesta de documentos (iniy-ingest: PDF / EPUB) === +pdf-extract = "0.7" +epub = "2.1" + +# === Bulk import Wikipedia (iniy-wiki dump) === +bzip2 = "0.4" + +# === Compresión (minga multi-bundle) === +zstd = "0.13" + +# === HTTP server (iniy-server) === +axum = "0.7" +tower = "0.5" + +# === ANN sobre embeddings (iniy nli --ann) === +instant-distance = "0.6" + +# === P2P (minga) === +libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] } +libp2p-stream = "=0.4.0-alpha" +libp2p-allow-block-list = "0.6" + +# === SSH (ssh, sandokan RemoteEngine, matilda) === +russh = "0.54" + +# === Math determinista cross-platform (dominium) === +libm = "0.2" + +# === SMF (takiy-midi) === +# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path. +midly = "0.5" + +# === Code parsing (minga) === +arboard = "3" +ropey = "1.6" +tree-sitter = "0.24" +tree-sitter-rust = "0.23" +tree-sitter-python = "0.23" +tree-sitter-typescript = "0.23" +tree-sitter-javascript = "0.23" +tree-sitter-go = "0.23" + +# === FS notify === +notify = "6.1" + +# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) === +petgraph = "0.6" + +# === Image decoding (nahual-image-viewer-llimphi) === +# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless). +# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app +# los pide específicamente. +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } + +# === FUSE (minga-vfs) === +# default-features = false: prescinde de pkg-config/libfuse-dev en build. +# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime). +fuser = { version = "0.15", default-features = false } + +# === CLI / auth (minga) === +clap = { version = "4", features = ["derive"] } +rpassword = "7" + +# === PAM (auth-core) === +pam = "0.8" + +# === D-Bus (arje compat) === +zbus = { version = "4", default-features = false, features = ["tokio"] } + +# === Tests === +tempfile = "3" + +# === Llimphi (motor gráfico soberano) === +# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux. +# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24. +# vello 0.5 = rasterizador vectorial sobre wgpu 24. +# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos). +# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone). +wgpu = "24" +winit = "0.30" +raw-window-handle = "0.6" +pollster = "0.4" +vello = "0.5" +taffy = "0.9" +# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break). +parley = "0.4" +# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps. +llimphi-ui = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Paleta semántica compartida por las apps y los widgets. +llimphi-theme = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Tweens y helpers de animación sobre el bucle Elm. +llimphi-motion = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps. +llimphi-icons = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Widgets reusables sobre llimphi-ui — uno por crate. +llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-menubar = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Sello vectorial wawa (rombo + W implícita + Merkle Core). +llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Widgets de elegancia transversal (tooltip, spinner, progress, toast, +# modal, empty, status-bar, shortcuts-help, splash). +llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Controles de formulario y signaling (switch, segmented, breadcrumb, +# badge, avatar, skeleton, field). +llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Firma visual transversal (gradient sutil + hairline accent). +llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/gioser.git" } +llimphi-workspace = { git = "https://gitea.gioser.net/sergio/gioser.git" } +# Abstracción Selector — host (paths) + wawa (khipus). +llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# === Filesystem helpers === +directories = "5" + +# === Diff line-based (llimphi-module-diff-viewer) === +# `similar` es la crate de facto: implementa Myers + Patience + LCS, +# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete), +# zero deps fuera de std. La 2.x es estable hace años. +similar = "2" + +# === Fuzzy matching (shuma-history) === +# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct, +# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple +# que necesitamos (Matcher + Pattern + score). +nucleo-matcher = "0.3" + +# === Transporte autenticado (shuma-link) === +# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente +# conoce la pubkey del servidor, server descubre la del cliente y la +# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s. +# La versión 0.9 viene pinneada por libp2p, así nos alineamos. +snow = "0.9" +hex = "0.4" + +# === PTY + emulador de terminal (shuma-exec, módulos REPL) === +# portable-pty aloja un PTY cross-platform; lo usamos para los +# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad. +# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor +# movement + erase + screen state) y mantiene un buffer de pantalla +# renderizable como grid. +portable-pty = "0.9" +vt100 = "0.16" + +# === WASM web (gioser) === +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = "0.3" +glam = "0.30" + +# === Markdown (pluma) === +pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] } + +# === Archivos comprimidos (nahual archive viewer) === +# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos, +# por eso default-features=false alcanza para ZIP. Para tar.gz sí +# descomprimimos en streaming con flate2 (ya declarado arriba), saltando +# los datos de cada entrada — sólo leemos headers. +zip = { version = "2.4", default-features = false } +tar = { version = "0.4", default-features = false } + +# === Fuentes (nahual font viewer) === +# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths. +ttf-parser = "0.25" + +# ============================================================ +# Intra-workspace deps de nahual (referenciadas por workspace = true) +# ============================================================ +nahual-text-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-image-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-thumb-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-gallery-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-video-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-card-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-audio-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-tree-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-hex-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-table-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-markdown-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-archive-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-font-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-map-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-geo-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-viewer-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +nahual-file-explorer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# ============================================================ +# Intra-workspace deps de pineal (módulo de gráficos) +# ============================================================ +pineal-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-render = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-cartesian = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-stream = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-mesh = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-financial = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-polar = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-heatmap = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-treemap = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-flow = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-phosphor = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-export = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-hexbin = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-contour = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal-bars = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pineal = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# ============================================================ +# Intra-workspace deps de iniy (laboratorio semántico de creencias) +# ============================================================ +iniy-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-ingest = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-extract = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-nli = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-nli-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-graph = { git = "https://gitea.gioser.net/sergio/gioser.git" } +iniy-store = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# === auto: declarados por crates internos faltantes === +cosmos-coords = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-ephemeris = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-time = { git = "https://gitea.gioser.net/sergio/gioser.git" } +cosmos-wcs = { git = "https://gitea.gioser.net/sergio/gioser.git" } + +# === auto: externas de eternal === +celestial-eop-data = { version = "0.1"} +approx = "0.5" +byteorder = "1.5" +cc = "1.0" +chrono = "0.4" +crc32fast = "1.4" +criterion = "0.5" +csv = "1.4" +flate2 = "1.0" +glob = "0.3" +indicatif = "0.18" +lz4_flex = "0.11" +memmap2 = "0.9" +mockito = "1.0" +ndarray = "0.15" +num-traits = "0.2" +once_cell = "1.19" +parking_lot = "0.12" +png = "0.18" +proptest = "1.4" +quick-xml = "0.31" +rayon = "1.8" +regex = "1.11" +reqwest = "0.12" +tiff = "0.11" +wide = "0.7" +wiremock = "0.6" + +# === i18n (rimay-localize) === +fluent-bundle = "0.15" +unic-langid = { version = "0.9", features = ["macros"] } +sys-locale = "0.3" + +# === Servo (puriy-engine) === +# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever +# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no +# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente +# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer +# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista, +# evita pull de tokio en el engine. +html5ever = "0.39" +markup5ever = "0.39" +markup5ever_rcdom = "0.39" +cssparser = "0.35" +url = "2" +ureq = { version = "2", default-features = false, features = ["tls"] } + +# === takiy-synth (SoundFont MIDI) === +# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador +# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc). +rustysynth = "1.3" + +# === takiy-playback (audio device output) === +# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en +# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para +# abrir el device default y empujar muestras f32 — nada de mezclado +# ni efectos en el callback. +cpal = "0.15" + +# === media-source-wav (decoder PCM en disco) === +# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM +# entero (8/16/24/32) y float (32). Suficiente para abrir samples y +# stems de prueba sin meter ffmpeg/symphonia. +hound = "3.5" + +# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) === +# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre +# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre +# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg) +# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac: +# ese tier patentado entra por shared/foreign-av. +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] } + +# === media-source-opus (decoder Opus NATIVO puro-Rust) === +# Opus es el formato de audio nativo de gioser (par del video AV1). ogg +# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus +# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video. +ogg = "0.9" +opus-wave = "3" + +# === media-source-webm (demux nativo Matroska/WebM) === +# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los +# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1 +# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo. +matroska-demuxer = "0.7" +# === git-deps al monorepo (agregados por la extracción) === +arje-incarnate = { git = "https://gitea.gioser.net/sergio/gioser.git" } +card-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +card-sidecar = { git = "https://gitea.gioser.net/sergio/gioser.git" } +minga-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +minga-store = { git = "https://gitea.gioser.net/sergio/gioser.git" } +minga-vfs = { git = "https://gitea.gioser.net/sergio/gioser.git" } +pata-host = { git = "https://gitea.gioser.net/sergio/gioser.git" } +rimay-localize = { git = "https://gitea.gioser.net/sergio/gioser.git" } +sandokan-core = { git = "https://gitea.gioser.net/sergio/gioser.git" } +sandokan-local = { git = "https://gitea.gioser.net/sergio/gioser.git" } +shuma-discern = { git = "https://gitea.gioser.net/sergio/gioser.git" } +ssh = { git = "https://gitea.gioser.net/sergio/gioser.git" } +wawa-config = { git = "https://gitea.gioser.net/sergio/gioser.git" } +wawa-config-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ede9631 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sergio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a704fd6 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# shuma + +> An interactive shell with zsh/fish parity — multiplexer and remote sessions built in — in Rust, on [Llimphi](https://gitea.gioser.net/sergio/llimphi). + +`shuma` replaces `zsh + tmux + mosh` with a single piece: a shell with history, completion and job-control, **native multiplexing** (no tmux), **remote sessions** (no mosh, no SSH daemon), all inside a Llimphi 4-slot chassis (TopBar · Main · BottomBar · Quake drawer). Optional `intent → command` inference layers an LLM on top — without it, the traditional shell runs unchanged. `matilda` is the sibling tool for declarative multi-host configuration. + +## Run + +```sh +cargo run --release -p shuma-shell-llimphi # the GUI shell (Llimphi chassis) +cargo run --release -p shuma-cli # CLI front-end +cargo run --release -p shuma-daemon # session daemon (local + remote) +``` + +## Architecture + +- **`shuma-core`** — shell engine: parsing, history, completion, job control. UI-agnostic. +- **`shuma-shell-llimphi`** — the GPU chassis (4 slots + Quake drawer + command bar + launcher modules). +- **`shuma-protocol` / `shuma-remote-exec`** — local-client ↔ remote-server over TCP/TLS, no SSH daemon required. +- **`matilda-*`** — declarative multi-host config (the bare-metal sibling). + +## How dependencies work + +Clean front-door repo: it contains **only shuma's own crates**. Llimphi and every foundational dependency (content-addressed cards, P2P discovery via `chasqui`/`minga`, the `pata` panel host, `arje` process primitives, shared leaves) are pulled as git dependencies from the [`gioser`](https://gitea.gioser.net/sergio/gioser) monorepo — the suite's source of truth. No vendoring, no duplication; the first build clones the monorepo (cached afterwards). + +## Considerations + +- **Replacement, not addition.** With shuma you can drop zsh/tmux/mosh — the behavior is covered. +- Remote sessions use `shuma-protocol`, not an SSH daemon. +- Cross-platform Llimphi UI; also runs inside the Wawa kernel. + +## License + +MIT. Builds on [Llimphi](https://gitea.gioser.net/sergio/llimphi) and the [gioser](https://gitea.gioser.net/sergio/gioser) suite.