feat(brahman-card-discovery): B4 — búsqueda de Cards local + DHT

- 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 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 15:23:16 +00:00
parent 27603c906d
commit 1e01dc27a5
7 changed files with 281 additions and 0 deletions
@@ -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<Dht>,
}
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<PeerId> {
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);
}
}
@@ -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<Card>,
}
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);
}
}
@@ -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;
@@ -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<CardIndex> {
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());
}
}