tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
1//! `llimphi-module-selector` — abstracción de "abrir/guardar" portable
2//! entre host (paths del FS) y wawa (khipus por hash).
3//!
4//! ## Por qué
5//!
6//! Una app tawasuyu que sólo conoce paths (`PathBuf`) se rompe en wawa,
7//! donde el almacenamiento es direccionado por contenido (BLAKE3 + DAG)
8//! y no existe el concepto de "carpeta /home/usuario". Pero la mayoría
9//! de las apps no necesitan saber la diferencia: sólo quieren preguntar
10//! "qué item quiere abrir el usuario" o "dónde guardo este blob".
11//!
12//! Este crate expone:
13//! - El trait [`Selector`] con dos métodos: `list_candidates()` (para
14//! armar la UI del picker) y `realize(handle)` (para resolver el
15//! item elegido a bytes).
16//! - Un `ItemHandle` opaco — la app no debe inspeccionarlo, sólo
17//! pasarlo de vuelta al selector.
18//! - [`HostSelector`] con root path + extension filter (impl real).
19//! - [`WawaSelector`] como **placeholder** con la API definida — la
20//! integración real con `akasha` / `wawa-kernel` ocurre cuando la
21//! suite empiece a correr in-cage. Por ahora exporta tipos y panica
22//! si se invoca, lo cual está bien: el código que lo construye
23//! queda compilable y las apps pueden tipar contra el trait.
24//!
25//! ## API mínima
26//!
27//! ```ignore
28//! use llimphi_module_selector::{HostSelector, Selector};
29//!
30//! let sel = HostSelector::new("/home/usr/docs", &[".pluma", ".khipu"]);
31//! let items = sel.list_candidates()?;
32//! // (la app muestra `items.iter().map(|i| &i.display_name)` en su picker)
33//! // user elige el index N:
34//! let bytes = sel.realize(&items[N].handle)?;
35//! ```
36
37#![forbid(unsafe_code)]
38
39use std::path::{Path, PathBuf};
40
41/// Resultado de la operación — `String` como error para que no le
42/// importe a la app si el backend es FS o wawa.
43pub type SelectorResult<T> = Result<T, String>;
44
45/// Item visible en el picker. `handle` es opaco — sólo el `Selector`
46/// que lo emitió sabe interpretarlo.
47#[derive(Debug, Clone)]
48pub struct Item {
49 /// Nombre legible para mostrar en el picker. Para `HostSelector`
50 /// es el path relativo al root; para `WawaSelector` será el alias
51 /// del khipu o un hash truncado si no tiene alias.
52 pub display_name: String,
53 /// Tamaño en bytes si se conoce — para mostrar al lado del nombre.
54 /// `None` cuando es caro de calcular (e.g. khipu blob remoto).
55 pub size_bytes: Option<u64>,
56 pub handle: ItemHandle,
57}
58
59/// Handle opaco. Internamente puede ser un path (host) o un hash
60/// (wawa). La app no debe construir uno a mano — lo recibe del
61/// `Selector` y se lo devuelve al `realize()`.
62#[derive(Debug, Clone)]
63pub enum ItemHandle {
64 /// Path absoluto en el FS del host.
65 HostPath(PathBuf),
66 /// Hash de contenido BLAKE3 (32 bytes hex) en el almacén wawa.
67 /// La integración real lo resuelve via `almacen::cargar(hash)`.
68 WawaHash([u8; 32]),
69}
70
71/// Trait que abstrae el medio de almacenamiento. Una app tawasuyu que
72/// quiera funcionar tanto en host como en wawa toma un `&dyn Selector`
73/// en su modelo en lugar de un `PathBuf` concreto.
74pub trait Selector {
75 /// Lista los items "abribles" según los criterios del selector
76 /// (extensión, glob, scope). Para host suele ser un walk del root;
77 /// para wawa, los khipus marcados con cierto namespace.
78 fn list_candidates(&self) -> SelectorResult<Vec<Item>>;
79
80 /// Resuelve un `ItemHandle` a los bytes del item.
81 fn realize(&self, handle: &ItemHandle) -> SelectorResult<Vec<u8>>;
82
83 /// Guarda `bytes` bajo el nombre lógico `name`. Devuelve el
84 /// `ItemHandle` del item recién creado. Para host esto es
85 /// `root.join(name) + write`; para wawa, ingerir en el almacén.
86 fn save(&self, name: &str, bytes: &[u8]) -> SelectorResult<ItemHandle>;
87}
88
89// =====================================================================
90// HostSelector — backend de FS clásico
91// =====================================================================
92
93/// Selector que walkea un root del filesystem y filtra por extensión.
94/// Implementación lineal — para roots gigantes la app debería cachear
95/// los candidates al arrancar (igual que hace el `file-picker` actual).
96pub struct HostSelector {
97 root: PathBuf,
98 /// Lista de extensiones aceptadas (con el punto, ej. `".pluma"`).
99 /// Vacío = todas.
100 extensions: Vec<String>,
101}
102
103impl HostSelector {
104 pub fn new(root: impl Into<PathBuf>, extensions: &[&str]) -> Self {
105 Self {
106 root: root.into(),
107 extensions: extensions.iter().map(|s| (*s).to_string()).collect(),
108 }
109 }
110
111 fn accept(&self, path: &Path) -> bool {
112 if self.extensions.is_empty() {
113 return true;
114 }
115 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
116 return false;
117 };
118 self.extensions.iter().any(|ext| name.ends_with(ext))
119 }
120
121 fn walk(&self, dir: &Path, out: &mut Vec<Item>) -> SelectorResult<()> {
122 let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
123 for entry in entries.flatten() {
124 let path = entry.path();
125 if path.is_dir() {
126 // Saltamos directorios "ruidosos" (target, .git, node_modules).
127 if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
128 if matches!(name, "target" | ".git" | "node_modules" | ".idea") {
129 continue;
130 }
131 }
132 self.walk(&path, out)?;
133 } else if self.accept(&path) {
134 let display_name = path
135 .strip_prefix(&self.root)
136 .map(|p| p.display().to_string())
137 .unwrap_or_else(|_| path.display().to_string());
138 let size_bytes = entry.metadata().ok().map(|m| m.len());
139 out.push(Item {
140 display_name,
141 size_bytes,
142 handle: ItemHandle::HostPath(path),
143 });
144 }
145 }
146 Ok(())
147 }
148}
149
150impl Selector for HostSelector {
151 fn list_candidates(&self) -> SelectorResult<Vec<Item>> {
152 let mut out = Vec::new();
153 self.walk(&self.root, &mut out)?;
154 Ok(out)
155 }
156
157 fn realize(&self, handle: &ItemHandle) -> SelectorResult<Vec<u8>> {
158 match handle {
159 ItemHandle::HostPath(p) => std::fs::read(p).map_err(|e| e.to_string()),
160 ItemHandle::WawaHash(_) => Err("HostSelector no resuelve hashes wawa".into()),
161 }
162 }
163
164 fn save(&self, name: &str, bytes: &[u8]) -> SelectorResult<ItemHandle> {
165 let path = self.root.join(name);
166 if let Some(parent) = path.parent() {
167 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
168 }
169 std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
170 Ok(ItemHandle::HostPath(path))
171 }
172}
173
174// =====================================================================
175// WawaSelector — placeholder para integración con akasha/almacen
176// =====================================================================
177
178/// Selector para entorno wawa. **No implementado** — la integración real
179/// requiere bindings al `wawa-kernel::almacen` (BLAKE3 + log + GC), que
180/// vive fuera del workspace global. Por ahora expone la API para que el
181/// código que la usa compile, y panica en runtime para flaggear que
182/// alguien intentó usarla antes de tiempo.
183///
184/// Cuando llegue la integración real:
185/// 1. `wawa-kernel` exporta una crate `wawa-almacen-client` cross-bound
186/// accesible desde apps WASM.
187/// 2. `WawaSelector::new(namespace)` se conecta a ese cliente.
188/// 3. `list_candidates()` consulta `almacen::listar(namespace)`.
189/// 4. `realize(WawaHash(h))` invoca `almacen::cargar(h)`.
190/// 5. `save(name, bytes)` invoca `almacen::ingerir(bytes)` y registra
191/// el alias `name → hash`.
192pub struct WawaSelector {
193 /// Namespace lógico (ej. `"pluma.documentos"`) — el almacén filtra
194 /// los khipus marcados con este tag.
195 pub namespace: String,
196}
197
198impl WawaSelector {
199 pub fn new(namespace: impl Into<String>) -> Self {
200 Self { namespace: namespace.into() }
201 }
202}
203
204impl Selector for WawaSelector {
205 fn list_candidates(&self) -> SelectorResult<Vec<Item>> {
206 Err(format!(
207 "WawaSelector('{}') sin backend wawa registrado — pendiente de integración con wawa-almacen-client",
208 self.namespace
209 ))
210 }
211
212 fn realize(&self, _handle: &ItemHandle) -> SelectorResult<Vec<u8>> {
213 Err("WawaSelector::realize sin backend wawa registrado".into())
214 }
215
216 fn save(&self, _name: &str, _bytes: &[u8]) -> SelectorResult<ItemHandle> {
217 Err("WawaSelector::save sin backend wawa registrado".into())
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn host_selector_accept_with_extensions() {
227 let s = HostSelector::new("/tmp", &[".pluma", ".khipu"]);
228 assert!(s.accept(Path::new("/tmp/foo.pluma")));
229 assert!(s.accept(Path::new("/tmp/bar.khipu")));
230 assert!(!s.accept(Path::new("/tmp/baz.txt")));
231 }
232
233 #[test]
234 fn host_selector_empty_extensions_accepts_all() {
235 let s = HostSelector::new("/tmp", &[]);
236 assert!(s.accept(Path::new("/tmp/anything.rs")));
237 assert!(s.accept(Path::new("/tmp/anything.unknown")));
238 }
239
240 #[test]
241 fn wawa_selector_returns_err_until_backend_lands() {
242 let s = WawaSelector::new("pluma.documentos");
243 assert!(s.list_candidates().is_err());
244 }
245}