⎇ 67c0fcad114fa1b0bac09727ce41ff058d1d402d
crates/protocol/brahman-cards/src/lib.rs
History
Who touched what
raw
refactor(loader): A3 — unificar loader, eliminar duplicación
1//! `brahman-cards` — brazo unificado de Cards.
2//!
3//! Brahman maneja varios formatos legítimos de "Card" (la unidad
4//! declarativa que describe identidad, datos, módulos, widgets, ...).
5//! Cada formato vive en su propio crate de origen y conserva su shape
6//! público; lo que este crate aporta es **un único punto de entrada**
7//! que sabe interpretar cada uno de ellos y proyectarlos a una sola
8//! estructura interna canónica [`Card`].
9//!
10//! Diseño:
11//!
12//! ```text
13//! ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
14//! │ Ente JSON │ │ Monad JSON │ │ UiModule │ … futuro
15//! │ (brahman- │ │ (chasqui- │ │ (nakui-ui- │
16//! │ card) │ │ card) │ │ schema) │
17//! └─────┬───────┘ └──────┬───────┘ └──────┬──────┘
18//! │ │ │
19//! └────────┬────────┴────────┬────────┘
20//! │ brahman-cards │
21//! │ (este crate) │
22//! └────────┬────────┘
23//! │
24//! ┌──────▼──────┐
25//! │ `Card` │ ← único tipo canónico
26//! │ wrapper │ que consumen UI runtime,
27//! │ común + │ storage, DHT, wire.
28//! │ variant │
29//! │ body │
30//! └─────────────┘
31//! ```
32//!
33//! Los formatos NO se disuelven. Si en el futuro hay que soportar un
34//! formato simplificado nuevo, se agrega un reader acá y nadie aguas
35//! abajo se entera — siguen recibiendo `Card`.
36//!
37//! V1 (este commit) sólo soporta inputs JSON. La extensión a Nickel
38//! (con templates de defaults vía merge nativo de Nickel) llega en un
39//! commit separado para aislar la dependencia `nickel-lang-core`.
40
41#![forbid(unsafe_code)]
42
43use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45
46use serde::{Deserialize, Serialize};
47use serde_json::Value;
48use thiserror::Error;
49
50pub use brahman_card::Card as EnteCard;
51pub use chasqui_card::MonadManifest;
52pub use nahual_meta_schema::Module as UiModuleSpec;
53
54/// Estructura canónica única que consumen los downstream del sistema
55/// (UI runtime, storage, DHT, wire). Cada formato input se proyecta
56/// a ésta vía un reader del brazo.
57///
58/// El wrapper común agrupa lo que TODOS los formatos comparten
59/// (identidad legible + extensiones forward-compat); el body preserva
60/// el typing rico de cada dominio sin colapsarlos.
61// PartialEq se omite porque algunos body variants vienen de crates
62// que no lo implementan (MonadManifest, nahual_meta_schema::Module).
63// Si downstream necesita igualdad, comparar via JSON round-trip o
64// agregar PartialEq en los crates origen.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Card {
67 /// Identificador opaco. String en el wrapper para no obligar a
68 /// los formatos a un mismo tipo concreto (Ente/Monad usan ULID,
69 /// UiModule usa slug human-friendly como `"sales_engine"`).
70 /// Cada reader documenta qué formato exige.
71 pub id: String,
72
73 /// Versión del schema canónico de este wrapper. Bump = romper
74 /// compat de los consumers downstream. Distinto de los
75 /// `schema_version` internos de cada body variant, que siguen
76 /// su propio versioning.
77 pub schema_version: u16,
78
79 /// Ancestro del que esta Card desciende (si aplica). Significado
80 /// específico al body variant (Ente: lineage del proceso; Monad:
81 /// split/merge de Mónada padre; UiModule: típicamente None).
82 #[serde(default)]
83 pub lineage: Option<String>,
84
85 /// Etiqueta humana legible. Cada reader la deriva del campo
86 /// equivalente del input (label/title/etc.).
87 pub label: String,
88
89 /// Campos no reconocidos del input se preservan acá. Permite
90 /// forward-compat: leer un input con campos nuevos no rompe la
91 /// carga, y volver a serializar conserva el extra.
92 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
93 pub extensions: BTreeMap<String, Value>,
94
95 /// Cuerpo tipado por dominio. La elección del variant es
96 /// responsabilidad del reader (basada en el input shape).
97 pub body: CardBody,
98}
99
100/// Versión actual del schema canónico de [`Card`]. Bump cuando cambie
101/// la shape del wrapper o las invariantes que comparten todos los
102/// variants.
103pub const CARD_SCHEMA_VERSION: u16 = 1;
104
105/// Variantes tipadas del body de [`Card`]. Una por dominio.
106///
107/// **Convención de extensión**: agregar un variant nuevo aquí + un
108/// reader que produzca ese variant. Los consumers que sólo manejen
109/// algunos variants pueden hacer `match { Ente(..) => ..., _ => skip }`.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(tag = "kind", rename_all = "snake_case")]
112pub enum CardBody {
113 /// Entidad runtime con proceso/payload/supervision (lo que era
114 /// `brahman_card::Card` directo).
115 Ente(EnteCard),
116
117 /// Agrupación semántica de archivos (Mónada de Nouser). No tiene
118 /// proceso; describe membership + signals semánticas (centroid,
119 /// keywords, lens).
120 Monad(MonadManifest),
121
122 /// Descriptor de módulo de UI: entities + views + menu + actions.
123 /// Lo que hoy lee la metainterface de Nakui desde
124 /// `examples/nakui-modules/<id>/module.json`.
125 UiModule(UiModuleSpec),
126}
127
128impl CardBody {
129 /// Etiqueta corta del variant — útil para mensajes de error y
130 /// dispatch en la UI sin necesitar match exhaustivo.
131 pub fn kind_name(&self) -> &'static str {
132 match self {
133 CardBody::Ente(_) => "ente",
134 CardBody::Monad(_) => "monad",
135 CardBody::UiModule(_) => "ui_module",
136 }
137 }
138}
139
140/// Errores de carga del brazo.
141#[derive(Debug, Error)]
142pub enum CardLoadError {
143 #[error("io: {0}")]
144 Io(#[from] std::io::Error),
145
146 #[error("parse JSON: {0}")]
147 JsonParse(#[from] serde_json::Error),
148
149 #[error("ningún reader registrado matcheó el input (shape no reconocido)")]
150 NoMatchingReader,
151
152 #[error("reader '{reader}' falló: {message}")]
153 ReaderFailed { reader: &'static str, message: String },
154
155 #[error("formato no soportado: extensión '{ext}'. Soportadas: {supported:?}")]
156 UnsupportedExtension {
157 ext: String,
158 supported: Vec<&'static str>,
159 },
160
161 #[error("evaluación Nickel: {0}")]
162 Nickel(#[from] NickelEvalError),
163}
164
165/// Trait de reader. Cada formato implementa una instancia.
166///
167/// El dispatcher del brazo (`load_card`) prueba los readers en el
168/// orden registrado y se queda con el primero cuyo `can_read`
169/// devuelve `true`. Por eso el orden importa: poner los más
170/// específicos antes que los más laxos.
171pub trait CardReader: Send + Sync {
172 /// Nombre del reader, para mensajes de error.
173 fn name(&self) -> &'static str;
174
175 /// Dado un JSON Value (el input ya parseado a serde Value),
176 /// decide si este reader puede manejarlo. Heurística estructural
177 /// — el shape del input identifica el formato, no flags
178 /// explícitos (los inputs legacy no los tienen).
179 fn can_read(&self, input: &Value) -> bool;
180
181 /// Produce el [`Card`] canónico. Sólo se llama si `can_read`
182 /// devolvió `true`.
183 fn read(&self, input: Value) -> Result<Card, CardLoadError>;
184}
185
186mod entity_loader;
187mod nickel_eval;
188mod readers;
189pub use entity_loader::{extract_card_from_json, load_card_file};
190pub use nickel_eval::{eval_nickel_file, NickelEvalError, BRAHMAN_CARDS_TEMPLATES_ENV};
191pub use readers::{EnteJsonReader, MonadJsonReader, UiModuleJsonReader};
192
193/// Path al directorio de templates Nickel canónicos shipped con el
194/// crate (`crates/core/brahman-cards/templates/` en el repo).
195///
196/// Este directorio contiene los `*_basic.ncl` para cada body kind:
197/// - `ente_basic.ncl`
198/// - `monad_basic.ncl`
199/// - `ui_module_basic.ncl`
200///
201/// Usar como path para [`BRAHMAN_CARDS_TEMPLATES_ENV`] o pasarlo
202/// directo a Nickel via env. Resuelto via `CARGO_MANIFEST_DIR` —
203/// funciona en `cargo test`/`cargo run` desde el workspace. Para
204/// distribución del binary standalone (cuando emerja el caso de
205/// uso), incluir los templates como recursos via `include_dir!` o
206/// instalar el directorio junto al ejecutable.
207pub fn canonical_templates_dir() -> PathBuf {
208 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates")
209}
210
211/// Construye el set default de readers para inputs JSON. El orden
212/// es deliberado: el más específico (UiModule, que tiene `entities`
213/// y `views` simultáneamente) antes que el más laxo. Si dos readers
214/// matchean, gana el primero.
215pub fn default_readers() -> Vec<Box<dyn CardReader>> {
216 vec![
217 Box::new(UiModuleJsonReader),
218 Box::new(MonadJsonReader),
219 Box::new(EnteJsonReader),
220 ]
221}
222
223/// Carga un Card desde una ruta. Detecta formato por extensión, y
224/// dentro de JSON detecta el shape probando los readers default en
225/// orden.
226///
227/// Para custom reader sets, usar [`load_card_with`].
228pub fn load_card(path: impl AsRef<Path>) -> Result<Card, CardLoadError> {
229 load_card_with(path, &default_readers())
230}
231
232/// Variante de [`load_card`] con readers custom. Útil para tests o
233/// para apps que quieren restringir formatos soportados.
234pub fn load_card_with(
235 path: impl AsRef<Path>,
236 readers: &[Box<dyn CardReader>],
237) -> Result<Card, CardLoadError> {
238 let path = path.as_ref();
239 let ext = path
240 .extension()
241 .and_then(|e| e.to_str())
242 .unwrap_or("")
243 .to_ascii_lowercase();
244 match ext.as_str() {
245 "json" => {
246 let bytes = std::fs::read(path)?;
247 let value: Value = serde_json::from_slice(&bytes)?;
248 dispatch_to_reader(value, readers)
249 }
250 "ncl" => {
251 // Nickel pipeline: leer archivo → evaluar deeply → exportar
252 // a JSON → parsear como Value → dispatch a los readers JSON
253 // estándar. Templates funcionan via los `import` nativos de
254 // Nickel; el evaluator resuelve relativo al input y al
255 // `BRAHMAN_CARDS_TEMPLATES_DIR` env (si está set).
256 let value = eval_nickel_file(path)?;
257 dispatch_to_reader(value, readers)
258 }
259 other => Err(CardLoadError::UnsupportedExtension {
260 ext: other.to_string(),
261 supported: vec!["json", "ncl"],
262 }),
263 }
264}
265
266/// Recorre los readers en orden, se queda con el primero que matchea
267/// y delega la conversión.
268fn dispatch_to_reader(
269 input: Value,
270 readers: &[Box<dyn CardReader>],
271) -> Result<Card, CardLoadError> {
272 for r in readers {
273 if r.can_read(&input) {
274 return r.read(input);
275 }
276 }
277 Err(CardLoadError::NoMatchingReader)
278}
279
280/// Filenames convencionales que [`load_cards_from_dir`] busca dentro
281/// de cada subdir, en orden de preferencia. Si `card.ncl` existe se
282/// usa ese; sino `card.json`; sino los aliases legacy `module.*`. Los
283/// últimos dos son por compat con el layout actual de
284/// `examples/nakui-modules/<id>/module.json`.
285pub const DEFAULT_CARD_FILENAMES: &[&str] =
286 &["card.ncl", "card.json", "module.ncl", "module.json"];
287
288/// Carga todas las Cards encontradas como subdirs inmediatos de
289/// `dir`. Por cada subdir, busca los filenames convencionales (ver
290/// [`DEFAULT_CARD_FILENAMES`]) y carga el primero que existe. Subdirs
291/// sin ningún filename matching se skipean silenciosamente — permite
292/// que un dir contenga subdirs auxiliares (assets, fixtures, etc.).
293///
294/// Devuelve las Cards en orden lexicográfico por subdir name (estable
295/// across runs). NO ordena por `Card.id` — el caller decide si quiere
296/// re-ordenar y/o dedupar.
297///
298/// Errores: cualquier I/O al leer el `dir` mismo, o cualquier
299/// `CardLoadError` de un archivo encontrado (NO continúa tras el
300/// primer fallo — fallo loud > corrupción silenciosa).
301pub fn load_cards_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Card>, CardLoadError> {
302 load_cards_from_dir_with(dir, DEFAULT_CARD_FILENAMES, &default_readers())
303}
304
305/// Variante de [`load_cards_from_dir`] con filenames y readers
306/// custom. Útil para apps que quieren restringir formatos o usar un
307/// nombre canónico distinto.
308pub fn load_cards_from_dir_with(
309 dir: impl AsRef<Path>,
310 filenames: &[&str],
311 readers: &[Box<dyn CardReader>],
312) -> Result<Vec<Card>, CardLoadError> {
313 let dir = dir.as_ref();
314 let mut subdir_paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)?
315 .flatten()
316 .filter_map(|e| {
317 let p = e.path();
318 if p.is_dir() { Some(p) } else { None }
319 })
320 .collect();
321 // Orden estable por subdir name — el output del brazo no debería
322 // depender del orden de read_dir (que varía por filesystem).
323 subdir_paths.sort();
324
325 let mut out: Vec<Card> = Vec::new();
326 for sub in subdir_paths {
327 for fname in filenames {
328 let candidate = sub.join(fname);
329 if candidate.exists() {
330 out.push(load_card_with(&candidate, readers)?);
331 break;
332 }
333 }
334 }
335 Ok(out)
336}