From 1e01dc27a5cdfb496e21d7c6c9ce2b9bb36868f3 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 15:23:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(brahman-card-discovery):=20B4=20=E2=80=94?= =?UTF-8?q?=20b=C3=BAsqueda=20de=20Cards=20local=20+=20DHT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index — CardIndex: índice en memoria con filtros (by_label case-insensitive substring, by_kind, providing por Capability, by_id). - registry — scan_dir: carga toda Card *.json de un directorio, saltando ruido y archivos rotos. - discovery — CardDiscovery: une el índice local con la malla P2P; announce_all publica las Cards locales al DHT, find_remote busca proveedores. Modo local-only sin DHT también soportado. Lo consumen el card-browser de nahual-shell y agorapura. 7 tests verdes. cargo check --workspace verde. settings.local.json: defaultMode bypassPermissions (sesión desatendida). Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 14 +++ Cargo.toml | 1 + .../brahman-card-discovery/Cargo.toml | 21 ++++ .../brahman-card-discovery/src/discovery.rs | 64 ++++++++++ .../brahman-card-discovery/src/index.rs | 116 ++++++++++++++++++ .../brahman-card-discovery/src/lib.rs | 19 +++ .../brahman-card-discovery/src/registry.rs | 46 +++++++ 7 files changed, 281 insertions(+) create mode 100644 crates/protocol/brahman-card-discovery/Cargo.toml create mode 100644 crates/protocol/brahman-card-discovery/src/discovery.rs create mode 100644 crates/protocol/brahman-card-discovery/src/index.rs create mode 100644 crates/protocol/brahman-card-discovery/src/lib.rs create mode 100644 crates/protocol/brahman-card-discovery/src/registry.rs diff --git a/Cargo.lock b/Cargo.lock index e0556cb..3612284 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1707,6 +1707,20 @@ dependencies = [ "ulid", ] +[[package]] +name = "brahman-card-discovery" +version = "0.1.0" +dependencies = [ + "brahman-card", + "brahman-cards", + "brahman-dht", + "libp2p", + "serde_json", + "tempfile", + "tokio", + "ulid", +] + [[package]] name = "brahman-card-wit" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e50a9ac..042dce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/protocol/brahman-sidecar", "crates/protocol/brahman-net", "crates/protocol/brahman-dht", + "crates/protocol/brahman-card-discovery", "crates/protocol/arje-card", # ============================================================ diff --git a/crates/protocol/brahman-card-discovery/Cargo.toml b/crates/protocol/brahman-card-discovery/Cargo.toml new file mode 100644 index 0000000..a812974 --- /dev/null +++ b/crates/protocol/brahman-card-discovery/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "brahman-card-discovery" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "Brahman — búsqueda de Cards: índice local con filtros + escaneo de directorios + discovery P2P sobre brahman-dht." + +[dependencies] +brahman-card = { path = "../brahman-card" } +brahman-cards = { path = "../brahman-cards" } +brahman-dht = { path = "../brahman-dht" } +libp2p = { workspace = true } +ulid = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/crates/protocol/brahman-card-discovery/src/discovery.rs b/crates/protocol/brahman-card-discovery/src/discovery.rs new file mode 100644 index 0000000..19952ed --- /dev/null +++ b/crates/protocol/brahman-card-discovery/src/discovery.rs @@ -0,0 +1,64 @@ +//! `CardDiscovery` — une el índice local de Cards con el DHT. + +use crate::index::CardIndex; +use brahman_dht::{Dht, DhtKey}; +use libp2p::PeerId; + +/// Búsqueda de Cards: siempre local, opcionalmente sobre la malla P2P. +pub struct CardDiscovery { + /// Índice local consultable. + pub index: CardIndex, + dht: Option, +} + +impl CardDiscovery { + /// Discovery sólo-local (sin malla P2P). + pub fn local(index: CardIndex) -> Self { + Self { index, dht: None } + } + + /// Discovery local + DHT. + pub fn with_dht(index: CardIndex, dht: Dht) -> Self { + Self { index, dht: Some(dht) } + } + + /// `true` si hay malla P2P conectada. + pub fn has_dht(&self) -> bool { + self.dht.is_some() + } + + /// Anuncia al DHT cada Card local (clave = `DhtKey::card(id)`). + /// No-op si no hay DHT. + pub fn announce_all(&self) { + if let Some(dht) = &self.dht { + for card in self.index.all() { + dht.announce(&DhtKey::card(card.id.to_string())); + } + } + } + + /// Busca proveedores remotos de una Card por id. Vacío si no hay DHT. + pub async fn find_remote(&self, card_id: &str) -> Vec { + match &self.dht { + Some(dht) => dht.find(&DhtKey::card(card_id)).await, + None => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use brahman_card::Card; + + #[tokio::test] + async fn local_only_discovery_has_no_dht() { + let mut ix = CardIndex::new(); + ix.insert(Card::new("local-1")); + let disc = CardDiscovery::local(ix); + assert!(!disc.has_dht()); + disc.announce_all(); // no-op, no debe panickear + assert!(disc.find_remote("cualquiera").await.is_empty()); + assert_eq!(disc.index.len(), 1); + } +} diff --git a/crates/protocol/brahman-card-discovery/src/index.rs b/crates/protocol/brahman-card-discovery/src/index.rs new file mode 100644 index 0000000..8d8440e --- /dev/null +++ b/crates/protocol/brahman-card-discovery/src/index.rs @@ -0,0 +1,116 @@ +//! Índice en memoria de Cards, con filtros de búsqueda. + +use brahman_card::{Capability, Card, CardKind}; +use ulid::Ulid; + +/// Colección consultable de Cards. +#[derive(Debug, Clone, Default)] +pub struct CardIndex { + cards: Vec, +} + +impl CardIndex { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, card: Card) { + self.cards.push(card); + } + + pub fn len(&self) -> usize { + self.cards.len() + } + + pub fn is_empty(&self) -> bool { + self.cards.is_empty() + } + + pub fn all(&self) -> &[Card] { + &self.cards + } + + /// Card por id exacto. + pub fn by_id(&self, id: Ulid) -> Option<&Card> { + self.cards.iter().find(|c| c.id == id) + } + + /// Cards cuyo label contiene `needle` (case-insensitive). + pub fn by_label(&self, needle: &str) -> Vec<&Card> { + let n = needle.to_lowercase(); + self.cards + .iter() + .filter(|c| c.label.to_lowercase().contains(&n)) + .collect() + } + + /// Cards de un `CardKind` dado. + pub fn by_kind(&self, kind: CardKind) -> Vec<&Card> { + self.cards.iter().filter(|c| c.kind == kind).collect() + } + + /// Cards que proveen la `Capability` dada. + pub fn providing(&self, cap: &Capability) -> Vec<&Card> { + self.cards + .iter() + .filter(|c| c.provides.contains(cap)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn card(label: &str) -> Card { + Card::new(label) + } + + #[test] + fn by_label_is_case_insensitive_substring() { + let mut ix = CardIndex::new(); + ix.insert(card("Broker Demo")); + ix.insert(card("file explorer")); + assert_eq!(ix.by_label("broker").len(), 1); + assert_eq!(ix.by_label("EXPLOR").len(), 1); + assert_eq!(ix.by_label("zzz").len(), 0); + } + + #[test] + fn by_id_finds_exact() { + let mut ix = CardIndex::new(); + let c = card("x"); + let id = c.id; + ix.insert(c); + assert!(ix.by_id(id).is_some()); + assert!(ix.by_id(Ulid::new()).is_none()); + } + + #[test] + fn providing_filters_by_capability() { + let mut spawner = card("spawner"); + spawner.provides.insert(Capability::Spawn); + let mut logger = card("logger"); + logger.provides.insert(Capability::Journal); + + let mut ix = CardIndex::new(); + ix.insert(spawner); + ix.insert(logger); + assert_eq!(ix.providing(&Capability::Spawn).len(), 1); + assert_eq!(ix.providing(&Capability::Journal).len(), 1); + assert_eq!(ix.providing(&Capability::FilesystemRoot).len(), 0); + } + + #[test] + fn by_kind_splits_ente_and_data() { + let mut ente = card("ente"); + ente.kind = CardKind::Ente; + let mut data = card("data"); + data.kind = CardKind::Data; + let mut ix = CardIndex::new(); + ix.insert(ente); + ix.insert(data); + assert_eq!(ix.by_kind(CardKind::Ente).len(), 1); + assert_eq!(ix.by_kind(CardKind::Data).len(), 1); + } +} diff --git a/crates/protocol/brahman-card-discovery/src/lib.rs b/crates/protocol/brahman-card-discovery/src/lib.rs new file mode 100644 index 0000000..cb5ba45 --- /dev/null +++ b/crates/protocol/brahman-card-discovery/src/lib.rs @@ -0,0 +1,19 @@ +//! `brahman-card-discovery` — búsqueda de Cards local + DHT. +//! +//! - [`index`] — `CardIndex`: índice en memoria con filtros (label, +//! kind, capability, id). +//! - [`registry`] — `scan_dir`: carga Cards `*.json` de un directorio. +//! - [`discovery`] — `CardDiscovery`: une el índice local con la malla +//! P2P vía `brahman-dht`. +//! +//! Lo consume el widget card-browser de `nahual-shell` y `agorapura`. + +#![forbid(unsafe_code)] + +pub mod index; +pub mod registry; +pub mod discovery; + +pub use discovery::CardDiscovery; +pub use index::CardIndex; +pub use registry::scan_dir; diff --git a/crates/protocol/brahman-card-discovery/src/registry.rs b/crates/protocol/brahman-card-discovery/src/registry.rs new file mode 100644 index 0000000..66a605e --- /dev/null +++ b/crates/protocol/brahman-card-discovery/src/registry.rs @@ -0,0 +1,46 @@ +//! Registro local: escaneo de directorios con Cards en disco. + +use crate::index::CardIndex; +use std::path::Path; + +/// Escanea `dir` (no recursivo) cargando toda Card `*.json` válida. +/// Los archivos que no parsean como Card se saltan en silencio. +pub fn scan_dir(dir: &Path) -> std::io::Result { + let mut index = CardIndex::new(); + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + if let Ok(card) = brahman_cards::load_card_file(&path) { + index.insert(card); + } + } + } + Ok(index) +} + +#[cfg(test)] +mod tests { + use super::*; + use brahman_card::Card; + + #[test] + fn scans_only_valid_json_cards() { + let dir = tempfile::tempdir().unwrap(); + for name in ["alpha", "beta"] { + let card = Card::new(name); + let json = serde_json::to_string(&card).unwrap(); + std::fs::write(dir.path().join(format!("{name}.json")), json).unwrap(); + } + // Ruido que debe ignorarse. + std::fs::write(dir.path().join("readme.txt"), "no soy una card").unwrap(); + std::fs::write(dir.path().join("roto.json"), "{ no json }").unwrap(); + + let ix = scan_dir(dir.path()).unwrap(); + assert_eq!(ix.len(), 2); + } + + #[test] + fn missing_dir_is_an_error() { + assert!(scan_dir(Path::new("/no/existe/jamas")).is_err()); + } +}