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:
Generated
+14
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user