refactor(naming): A1 — ente→arje, vista→revista, pluma→fana

Rename batch de la Fase A del PLAN_MACRO:
- 25 crates ente-* → arje-* (protocol/init/runtime/compat). El linaje
  arje (init Linux) queda con prefijo coherente.
- vista → revista (revista-core + revista-web).
- pluma → fana (fana-md + fana-md-reader-web). fana absorbe el linaje
  markdown de pluma; será el writer DAG editor (prioridad alta).

Cambios:
- git mv de 29 crate dirs + 2 SDDs
- package/lib/bin names + path refs + imports .rs reescritos
- workspace Cargo.toml + comentarios de sección
- SDDs de init/runtime/compat/protocol actualizados a arje-
- SDD de revista + SDD de fana (reescrito: writer DAG editor)
- docs/STATUS.md, ROADMAP.md, PLAN_MACRO.md, arje-boot.md,
  arje-replace-systemd.md actualizados
- docs/changelog/akasha.md → chasqui.md

scripts/rename-fase-a.py idempotente (--dry-run soportado).
cargo check --workspace verde.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 00:10:14 +00:00
parent 3fc6dcfa72
commit b83d40a833
159 changed files with 2384 additions and 1111 deletions
@@ -0,0 +1,795 @@
//! `chasqui` CLI — explorador de Mónadas.
//!
//! Subcomandos:
//!
//! - `scan <dir>` recorre `dir` y muestra las Mónadas detectadas.
//! - `show <dir> <id?>` scan + detalles de la Mónada con prefijo de ID.
//! - `json <dir>` scan + dump JSON con los manifests.
//!
//! Phase A: in-memory, sin persistencia, sin brahman sidecar. La
//! sesión termina y todo se descarta. Phase B agrega persistencia y
//! presencia ante el Init.
use std::path::PathBuf;
use std::process::ExitCode;
use chasqui_core::{
cluster, db, embed,
scanner::{self, ScanConfig},
};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let prog = args.first().cloned().unwrap_or_else(|| "chasqui".into());
let sub = match args.get(1).map(String::as_str) {
Some(s) => s,
None => {
print_usage(&prog);
return ExitCode::from(2);
}
};
let rest = &args[2..];
let result = match sub {
"scan" => cmd_scan(rest),
"show" => cmd_show(rest),
"json" => cmd_json(rest),
"daemon" => cmd_daemon(rest),
"attract" => cmd_attract(rest),
"--help" | "-h" | "help" => {
print_usage(&prog);
return ExitCode::SUCCESS;
}
other => {
eprintln!("chasqui: comando desconocido '{other}'");
print_usage(&prog);
return ExitCode::from(2);
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("chasqui: {e}");
ExitCode::from(1)
}
}
}
fn print_usage(prog: &str) {
eprintln!("uso: {prog} <comando> [args]");
eprintln!();
eprintln!("comandos:");
eprintln!(" scan <dir> recorre un directorio y lista las Mónadas detectadas");
eprintln!(" show <dir> <prefix> scan + detalle de la Mónada cuyo ID empieza con <prefix>");
eprintln!(" json <dir> scan + dump JSON de todos los manifests");
eprintln!(" daemon <dir> scan + sidecarea cada Mónada al Init brahman");
eprintln!(" attract <dir> <file> dado un archivo, qué Mónada del scan lo atrae más");
eprintln!();
eprintln!("env:");
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
eprintln!(" NOUSER_DB_PATH si está set, abre sled en esa ruta (persistencia)");
eprintln!(" BRAHMAN_INIT_SOCKET socket del Init (heredado de brahman-handshake)");
}
type Cmd = Result<(), Box<dyn std::error::Error>>;
fn min_files() -> usize {
std::env::var("NOUSER_MIN_FILES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(cluster::DEFAULT_MIN_FILES_PER_MONAD)
}
fn require_dir(args: &[String]) -> Result<PathBuf, Box<dyn std::error::Error>> {
let dir = args.first().ok_or("falta argumento <dir>")?;
Ok(PathBuf::from(dir))
}
fn run_scan(dir: &PathBuf) -> Result<(db::MonadDb, usize), Box<dyn std::error::Error>> {
let files = scanner::scan_directory(dir, &ScanConfig::default())?;
let n_files = files.len();
let monads = cluster::by_directory(&files, min_files());
let mut db = open_db()?;
db.ingest_files(files);
db.replace_monads(monads);
Ok((db, n_files))
}
/// Abre el `MonadDb`. Si `NOUSER_DB_PATH` está set, persistencia sled;
/// si no, store en memoria.
fn open_db() -> Result<db::MonadDb, Box<dyn std::error::Error>> {
if let Ok(path) = std::env::var("NOUSER_DB_PATH") {
Ok(db::MonadDb::open(&path)?)
} else {
Ok(db::MonadDb::new())
}
}
fn cmd_scan(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let (db, n_files) = run_scan(&dir)?;
println!(
"scan: {} archivos en {}, {} mónadas (min_files={})",
n_files,
dir.display(),
db.monad_count(),
min_files()
);
if db.monad_count() == 0 {
println!(" (ninguna Mónada — bajá NOUSER_MIN_FILES o apuntá a un dir con más archivos)");
return Ok(());
}
println!();
for m in db.monads() {
let id_short = format!("{}", m.id);
let id_short = &id_short[..8];
println!(
" [{}] {:30} card={} ent={:.2} lens={:?}",
id_short, m.label, m.cardinality, m.entropy, m.dominant_lens,
);
if !m.keywords.is_empty() {
println!(" keywords: {}", m.keywords.join(", "));
}
}
Ok(())
}
fn cmd_show(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let prefix = args.get(1).ok_or("falta argumento <prefix>")?;
let (db, _) = run_scan(&dir)?;
let m = db
.monads()
.find(|m| m.id.to_string().starts_with(prefix))
.ok_or_else(|| format!("ninguna Mónada con prefijo '{prefix}'"))?;
println!("Monad {}", m.id);
println!(" label: {}", m.label);
println!(" summary: {}", m.summary);
println!(" cardinality: {}", m.cardinality);
println!(" entropy: {:.4}", m.entropy);
println!(" lens: {:?}", m.dominant_lens);
println!(" keywords: {}", m.keywords.join(", "));
println!(" members ({}):", m.members.len());
for f in db.resolve_members(m.id) {
println!(
" {:>10} bytes {}",
f.size,
f.path.display()
);
}
Ok(())
}
fn cmd_json(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let (db, _) = run_scan(&dir)?;
let manifests: Vec<_> = db.monads().cloned().collect();
println!("{}", serde_json::to_string_pretty(&manifests)?);
Ok(())
}
fn cmd_daemon(args: &[String]) -> Cmd {
let dir = require_dir(args)?;
let pool = std::sync::Arc::new(
brahman_sidecar::SidecarPool::new().map_err(|e| format!("crear pool: {e}"))?,
);
// 1. Decidir el path del query socket ANTES de armar el engine
// Card (porque viaja como service_socket en la Card).
let query_socket = chasqui_card::query::transport::default_socket_path();
// 2. Engine como Ente. Declara service_socket + flow.output para
// que el broker pueda emitir MatchEvent::Available a consumers
// interesados en `flow.input = monad-list:json`.
let engine_card = build_engine_card(query_socket.clone());
let engine_id = engine_card.id;
let engine_label = engine_card.label.clone();
eprintln!(
"chasqui daemon: publicando engine '{}' (kind=Ente, id={}, socket={})",
engine_label,
engine_id,
query_socket.display()
);
pool.spawn(engine_card);
// 2. Hidratación: si NOUSER_DB_PATH apunta a un sled poblado,
// publicar lo que ya tenemos ANTES del re-scan. brahman-status
// ve mónadas reales en milisegundos, no en segundos.
let mut db = open_db()?;
let prior_count = db.monad_count();
if prior_count > 0 {
let mut hydrated = 0usize;
let mut skipped_model = 0usize;
for monad in db.monads() {
// Sólo publicamos centroides del modelo actual; los demás
// son data muerta hasta que el re-scan los reemplace.
let valid = monad
.centroid_model
.as_deref()
.map(|id| id == embed::MODEL_ID)
.unwrap_or(false);
if !valid {
skipped_model += 1;
continue;
}
let mut card = monad.to_brahman_card();
card.references.push(brahman_card::CardReference {
kind: brahman_card::RelationshipKind::OwnedBy,
target_id: engine_id,
target_label: engine_label.clone(),
});
pool.spawn(card);
hydrated += 1;
}
eprintln!(
"chasqui daemon: hidratadas {} mónadas previas{} en O(1)",
hydrated,
if skipped_model > 0 {
format!(" ({} dropeadas por centroid_model distinto)", skipped_model)
} else {
String::new()
}
);
}
// 3. Re-scan con hidratación: las Mónadas con mismo path_hint
// reusan id, así que NO generamos sesiones duplicadas para los
// mismos directorios — el sidecar previo ya tiene esa identidad.
let files = scanner::scan_directory(&dir, &scanner::ScanConfig::default())?;
let n_files = files.len();
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(&db));
let scanned_count = monads.len();
eprintln!(
"chasqui daemon: re-scan {} archivos en {}{} mónadas",
n_files,
dir.display(),
scanned_count
);
// Publicamos sólo las Mónadas NUEVAS (las que no estaban en la
// hidratación inicial). El criterio: si el id estaba en la DB
// previa, el sidecar de la hidratación ya cubre esa identidad.
let prior_ids: std::collections::BTreeSet<_> = db.monads().map(|m| m.id).collect();
let mut newly_spawned = 0usize;
for monad in &monads {
if prior_ids.contains(&monad.id) {
continue; // ya publicada en hidratación
}
let mut card = monad.to_brahman_card();
card.references.push(brahman_card::CardReference {
kind: brahman_card::RelationshipKind::OwnedBy,
target_id: engine_id,
target_label: engine_label.clone(),
});
pool.spawn(card);
newly_spawned += 1;
}
// Reescribimos la DB con el set actual (idempotente para los
// hidratados; reemplazo para los nuevos).
db.ingest_files(files);
db.replace_monads(monads);
eprintln!(
"chasqui daemon: 1 ente + {} mónadas vivas ({} nuevas vs hidratación)",
scanned_count, newly_spawned
);
// Engine query socket: bind antes del watcher para que cualquier
// consumer descubierto vía broker pueda consultarnos enseguida.
// Si el bind falla, seguimos sin él — la UI degrada a "no
// alcanzable" pero el daemon sigue procesando cambios.
let db_shared = std::sync::Arc::new(std::sync::Mutex::new(db));
let _query_listener = match chasqui_core::engine_socket::spawn_listener(
chasqui_core::engine_socket::ListenerConfig {
socket_path: query_socket.clone(),
engine_id,
engine_label: engine_label.clone(),
watching: Some(dir.clone()),
},
db_shared.clone(),
) {
Ok(h) => {
eprintln!(
"chasqui daemon: query socket activo en {} (proto: chasqui_card::query)",
query_socket.display()
);
Some(h)
}
Err(e) => {
eprintln!(
"chasqui daemon: query socket NO disponible ({e}) — explorer no podrá consultar"
);
None
}
};
// Watcher: cada cambio en el árbol — coalescido con debounce de
// 150ms — dispara un re-scan + re-cluster del directorio y
// re-publica al broker las Mónadas afectadas (drop + spawn por id,
// gracias al replace en `SidecarPool::spawn`).
let _watcher = match spawn_fs_watcher(
dir.clone(),
db_shared.clone(),
pool.clone(),
engine_id,
engine_label.clone(),
) {
Ok(w) => {
eprintln!(
"chasqui daemon: watcher activo en {} (debounce 150ms, re-publish on) — Ctrl-C para terminar.",
dir.display()
);
Some(w)
}
Err(e) => {
eprintln!(
"chasqui daemon: watcher deshabilitado ({e}) — Ctrl-C para terminar."
);
None
}
};
std::thread::park();
drop(_watcher);
drop(_query_listener);
let _ = std::fs::remove_file(&query_socket); // best-effort cleanup
drop(pool);
Ok(())
}
/// Ventana de debounce: notify dispara Create+Modify(+) por cada
/// edición; sin coalescer veríamos N reacciones por un solo `:w`.
/// 150ms es generoso para editores típicos (vim/code) y mantiene el
/// feedback "vivo" para el usuario.
const WATCHER_DEBOUNCE_MS: u64 = 150;
/// Watcher de filesystem con debounce + re-publish al broker.
///
/// Pipeline:
///
/// 1. **notify** dispara eventos crudos a un canal interno.
/// 2. **dispatcher**: filtra a Create/Modify/Remove de paths bajo
/// `dir`, descarta el resto, reenvía al canal de debounce.
/// 3. **coordinator**: mantiene un `HashMap<PathBuf, Instant>`.
/// Cada vez que el canal queda en silencio durante
/// `WATCHER_DEBOUNCE_MS`, agrupa los paths cuya última actividad
/// superó la ventana y los procesa en **un solo batch**.
/// 4. **process_change_batch**: re-scan + re-cluster hidratado +
/// diff vs DB + `pool.drop_session` para Mónadas desaparecidas
/// + `pool.spawn` para Mónadas nuevas o con composición distinta.
/// `pool.spawn` reemplaza la sesión previa con el mismo `Card.id`,
/// así que el broker ve el manifest fresco sin sesiones huérfanas.
fn spawn_fs_watcher(
dir: std::path::PathBuf,
db: std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
pool: std::sync::Arc<brahman_sidecar::SidecarPool>,
engine_id: brahman_card::ulid::Ulid,
engine_label: String,
) -> Result<notify::RecommendedWatcher, Box<dyn std::error::Error>> {
use notify::{Event, EventKind, RecursiveMode, Watcher};
let (notify_tx, notify_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = notify_tx.send(res);
})?;
watcher.watch(&dir, RecursiveMode::Recursive)?;
let (path_tx, path_rx) = std::sync::mpsc::channel::<std::path::PathBuf>();
// Dispatcher: notify → filtro → canal de paths.
let dispatch_dir = dir.clone();
std::thread::Builder::new()
.name("chasqui-watcher-dispatch".into())
.spawn(move || {
for res in notify_rx {
let event = match res {
Ok(e) => e,
Err(e) => {
eprintln!("[watcher] error: {e}");
continue;
}
};
// Create/Modify viven; Remove también nos importa
// (puede colapsar Mónadas).
let interesting = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
);
if !interesting {
continue;
}
for path in event.paths {
if !path.starts_with(&dispatch_dir) {
continue;
}
let _ = path_tx.send(path);
}
}
})?;
// Coordinator: debounce + batch dispatch.
let coord_dir = dir;
std::thread::Builder::new()
.name("chasqui-watcher-coord".into())
.spawn(move || {
let debounce = std::time::Duration::from_millis(WATCHER_DEBOUNCE_MS);
let mut pending: std::collections::HashMap<
std::path::PathBuf,
std::time::Instant,
> = std::collections::HashMap::new();
loop {
match path_rx.recv_timeout(debounce) {
Ok(path) => {
pending.insert(path, std::time::Instant::now());
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
let now = std::time::Instant::now();
let due: Vec<std::path::PathBuf> = pending
.iter()
.filter(|(_, t)| now.duration_since(**t) >= debounce)
.map(|(p, _)| p.clone())
.collect();
if due.is_empty() {
continue;
}
for p in &due {
pending.remove(p);
}
process_change_batch(
&due,
&coord_dir,
&db,
&pool,
engine_id,
&engine_label,
);
}
})?;
Ok(watcher)
}
/// Procesa un batch de paths cambiados: re-scanea el árbol, re-clusteriza
/// con hidratación, y propaga el delta de Mónadas al broker.
///
/// El re-scan global es deliberado: el clustering por directorio es global
/// por diseño, así que un cambio en `src/foo.rs` puede mover Mónadas en
/// `src/` sin tocar `tests/`. Coste O(N archivos), aceptable para
/// directorios típicos (<10k archivos). Optimizar a re-cluster parcial
/// cuando duela.
fn process_change_batch(
paths: &[std::path::PathBuf],
dir: &std::path::Path,
db: &std::sync::Arc<std::sync::Mutex<db::MonadDb>>,
pool: &std::sync::Arc<brahman_sidecar::SidecarPool>,
engine_id: brahman_card::ulid::Ulid,
engine_label: &str,
) {
eprintln!(
"[watcher] ⚙ batch: {} path(s) coalescidos → re-scan",
paths.len()
);
let files = match scanner::scan_directory(dir, &scanner::ScanConfig::default()) {
Ok(f) => f,
Err(e) => {
eprintln!("[watcher] re-scan falló: {e}");
return;
}
};
let mut db_lock = match db.lock() {
Ok(g) => g,
Err(_) => {
eprintln!("[watcher] mutex envenenado — abortando batch");
return;
}
};
let prior_monads: Vec<chasqui_card::MonadManifest> = db_lock.monads().cloned().collect();
let prior_ref: &db::MonadDb = &db_lock;
let monads = cluster::by_directory_hydrated(&files, min_files(), Some(prior_ref));
let prior_ids: std::collections::BTreeSet<_> =
prior_monads.iter().map(|m| m.id).collect();
let new_ids: std::collections::BTreeSet<_> = monads.iter().map(|m| m.id).collect();
// Mónadas que ya no existen (directorio quedó por debajo de
// min_files o fue removido): cerramos su sesión en el broker.
let mut removed = 0usize;
for id in prior_ids.difference(&new_ids) {
pool.drop_session(*id);
removed += 1;
if let Some(prev) = prior_monads.iter().find(|m| &m.id == id) {
eprintln!(
"[watcher] ✖ {} ({}) desapareció — sesión cerrada",
&id.to_string()[..8],
prev.label
);
}
}
// Mónadas nuevas o cuya composición cambió (members/centroid):
// (re)spawn — el pool reemplaza la sesión previa con el mismo id.
let mut respawned = 0usize;
let mut fresh = 0usize;
for monad in &monads {
let prev = prior_monads.iter().find(|m| m.id == monad.id);
let is_new = prev.is_none();
let changed = match prev {
Some(p) => p.members != monad.members || p.centroid != monad.centroid,
None => true,
};
if !changed {
continue;
}
let mut card = monad.to_brahman_card();
card.references.push(brahman_card::CardReference {
kind: brahman_card::RelationshipKind::OwnedBy,
target_id: engine_id,
target_label: engine_label.to_string(),
});
pool.spawn(card);
if is_new {
fresh += 1;
eprintln!(
"[watcher] ✦ {} nace ({} miembros, lens={:?})",
monad.label, monad.cardinality, monad.dominant_lens
);
} else {
respawned += 1;
let prev = prev.unwrap();
let delta_members = monad.members.len() as i64 - prev.members.len() as i64;
eprintln!(
"[watcher] ↻ {} refresh ({} miembros, Δ={:+})",
monad.label, monad.cardinality, delta_members
);
}
}
if removed == 0 && fresh == 0 && respawned == 0 {
eprintln!("[watcher] (sin cambios estructurales tras re-cluster)");
} else {
eprintln!(
"[watcher] ⌃ delta: {} nuevas, {} refrescadas, {} cerradas — {} sesiones vivas",
fresh,
respawned,
removed,
pool.live_sessions()
);
}
db_lock.ingest_files(files);
db_lock.replace_monads(monads);
}
fn cmd_attract(args: &[String]) -> Cmd {
let mut remote = false;
let mut positional: Vec<&String> = Vec::new();
for a in args {
if a == "--remote" {
remote = true;
} else {
positional.push(a);
}
}
let dir = positional
.first()
.map(|s| std::path::PathBuf::from(s.as_str()))
.ok_or("falta argumento <dir>")?;
let file_path = positional.get(1).ok_or("falta argumento <file>")?;
let file_path = std::path::PathBuf::from(file_path.as_str());
if !file_path.exists() {
return Err(format!("archivo no existe: {}", file_path.display()).into());
}
let (db, _) = run_scan(&dir)?;
// Construimos un FileEntry para el archivo objetivo.
let metadata = std::fs::metadata(&file_path)?;
let mtime_ms = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let target = chasqui_card::FileEntry {
id: chasqui_card::FileId::from(chasqui_card::ulid::Ulid::new()),
path: file_path.clone(),
content_hash: None,
size: metadata.len(),
mtime_ms,
extension: file_path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase()),
};
// Embedding del target + identificación del modelo que lo produjo.
// Local: pseudo-32d. Remote: lo que devuelva el provider electo
// (mock=pseudo-32d, real=fastembed-384d).
let (target_vec, target_model, source) = if remote {
let (v, model) = remote_embed(&target)?;
(v, model, "remote")
} else {
(
embed::embed(&target).to_vec(),
embed::MODEL_ID.to_string(),
"local",
)
};
// Filtramos Mónadas cuyo centroid_model NO matchee. Mezclar
// 32-d con 384-d daría scores sin sentido (diferente semántica
// y cosine no compara cross-modelo).
let mut ranked: Vec<(&chasqui_card::MonadManifest, f32)> = db
.monads()
.filter(|m| !m.centroid.is_empty())
.filter(|m| match &m.centroid_model {
Some(id) => id == &target_model,
None => true, // legacy sin tag — comparamos best-effort
})
.map(|m| (m, embed::attraction_score(&target_vec, m)))
.collect();
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let total_monads = db.monads().filter(|m| !m.centroid.is_empty()).count();
let skipped = total_monads - ranked.len();
if ranked.is_empty() {
println!("ninguna Mónada con centroide en {}", dir.display());
return Ok(());
}
println!("archivo: {}", file_path.display());
println!("scan dir: {}", dir.display());
println!("embed: {} ({})", source, target_model);
if skipped > 0 {
println!(
"skipped: {} mónada(s) con centroid_model distinto (no comparables)",
skipped
);
}
println!("ranking de atracción (cosine similarity):");
println!();
for (i, (m, score)) in ranked.iter().take(5).enumerate() {
let marker = if *score >= embed::DEFAULT_ATTRACTION_THRESHOLD && i == 0 {
"🧲"
} else if i == 0 {
"·"
} else {
" "
};
let id_short = format!("{}", m.id);
let id_short = &id_short[..8];
println!(
" {} {:.4} [{}] {:30} ({})",
marker, score, id_short, m.label, m.summary
);
}
if ranked[0].1 < embed::DEFAULT_ATTRACTION_THRESHOLD {
println!();
println!(
" (mejor score {:.4} < umbral {:.4} — el archivo no se 'pega' a ninguna)",
ranked[0].1,
embed::DEFAULT_ATTRACTION_THRESHOLD
);
}
Ok(())
}
/// Pipeline completo del modo `--remote`:
/// 1. Si `NOUSER_NOUS_SOCKET` está set, lo usa directo (override
/// explícito, atajo para tests).
/// 2. Si no, delega en `brahman_sidecar::await_provider_blocking` —
/// el sidecar se conecta al broker, registra un consumer Card con
/// `flow.input = embed-result:json`, espera el primer
/// `MatchEvent::Available` y devuelve el socket. Esto activa la
/// lógica de `priority_contexts`: bajo `BRAHMAN_BROKER_CONTEXT=test/prod`,
/// el proveedor electo cambia sin que este código toque nada.
/// 3. Con el socket resuelto, dispara la RPC `EmbedFile`.
///
/// Devuelve `(embedding, model_id)` — el caller necesita ambos para
/// comparar contra centroides taggeados con su mismo `centroid_model`.
fn remote_embed(
file: &chasqui_card::FileEntry,
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
if let Ok(explicit) = std::env::var("NOUSER_NOUS_SOCKET") {
let sock = std::path::PathBuf::from(explicit);
return embed_via(&sock, file);
}
let consumer = brahman_sidecar::build_consumer_card(
"chasqui.attract-cli",
chasqui_nous::FLOW_EMBED_RESULT,
chasqui_nous::FLOW_TYPE_NAME,
);
let producer_sock = brahman_sidecar::await_provider_blocking(
consumer,
std::time::Duration::from_secs(3),
)?;
embed_via(&producer_sock, file)
}
/// RPC blocking contra un socket chasqui-nous concreto. Devuelve
/// `(embedding, model_id)` — el `model_id` viaja en la response y
/// permite al caller saber qué centroides son comparables.
fn embed_via(
sock_path: &std::path::Path,
file: &chasqui_card::FileEntry,
) -> Result<(Vec<f32>, String), Box<dyn std::error::Error>> {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
if !sock_path.exists() {
return Err(format!("socket no existe: {}", sock_path.display()).into());
}
let mut stream = UnixStream::connect(sock_path)?;
let req = chasqui_nous::EmbedRequest {
kind: chasqui_nous::RequestKind::EmbedFile,
payload: serde_json::to_value(chasqui_nous::EmbedFilePayload {
path: file.path.display().to_string(),
extension: file.extension.clone(),
size: file.size,
mtime_ms: file.mtime_ms,
})?,
};
let line = serde_json::to_string(&req)?;
stream.write_all(line.as_bytes())?;
stream.write_all(b"\n")?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut response = String::new();
reader.read_line(&mut response)?;
if response.is_empty() {
return Err("chasqui-nous cerró sin respuesta".into());
}
if let Ok(resp) = serde_json::from_str::<chasqui_nous::EmbedResponse>(&response) {
return Ok((resp.embedding, resp.model));
}
let err: chasqui_nous::ErrorResponse = serde_json::from_str(&response)?;
Err(format!("chasqui-nous: {}", err.error).into())
}
/// Card del propio engine (kind=Ente). Es el "ser" que produce y
/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
///
/// Declara `service_socket` y `flow.output = monad-list:json` para
/// que un consumer (UI, CLI) pueda descubrir al daemon vía broker
/// MatchEvent y consultarle por sus Mónadas sin pasar por
/// brahman-admin.
fn build_engine_card(service_socket: std::path::PathBuf) -> brahman_card::Card {
use brahman_card::{Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef};
use chasqui_card::query::{FLOW_MONAD_LIST, FLOW_TYPE_NAME};
Card {
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Ente,
service_socket: Some(service_socket),
flow: Flows {
input: vec![],
output: vec![Flow {
name: FLOW_MONAD_LIST.into(),
ty: TypeRef::Primitive {
name: FLOW_TYPE_NAME.into(),
},
pin_to: None,
}],
},
..Card::new("brahman.nouser_engine")
}
}
+355
View File
@@ -0,0 +1,355 @@
//! Clustering determinista (Phase A).
//!
//! Estrategia: agrupar por **directorio padre** + ranking por
//! **extensión dominante**. No hay LLM ni embeddings — sólo metadatos.
//! Esta capa cubre el 90% de los casos prácticos:
//!
//! - Un proyecto Rust en `~/dev/foo/src/` → Mónada coherente (.rs).
//! - Un dump de fotos en `~/Pictures/2024/` → Mónada con lente Gallery.
//! - Notas en `~/notes/` → Mónada con lente Markdown.
//!
//! Los casos donde esta heurística falla (archivos relacionados pero
//! dispersos en el FS) son el dominio de los embeddings (Phase C) y
//! del clustering por Nous (Phase D).
use std::collections::BTreeMap;
use std::path::PathBuf;
use chasqui_card::{FileEntry, Lens, MonadManifest};
use crate::embed;
/// Mínimo de archivos para que un directorio sea promovido a Mónada.
/// Por debajo de eso, los archivos quedan "huérfanos" (no asignados).
pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
/// Agrupa archivos en Mónadas por directorio padre.
///
/// Devuelve un `Vec<MonadManifest>` ordenado por path. Archivos en
/// directorios con menos de `min_files` no producen Mónada.
pub fn by_directory(files: &[FileEntry], min_files: usize) -> Vec<MonadManifest> {
by_directory_hydrated(files, min_files, None)
}
/// Variante con hidratación: si `prior` está presente, busca Mónadas
/// previas con el mismo `path_hint` y `centroid_model` válido, y reusa
/// su `id` y `lineage`. Esto preserva identidad across re-scans —
/// fundamental para que el daemon pueda republicar tras hidratar de
/// sled sin generar duplicados en el broker.
pub fn by_directory_hydrated(
files: &[FileEntry],
min_files: usize,
prior: Option<&crate::db::MonadDb>,
) -> Vec<MonadManifest> {
let mut by_parent: BTreeMap<PathBuf, Vec<&FileEntry>> = BTreeMap::new();
for f in files {
if let Some(parent) = f.path.parent() {
by_parent.entry(parent.to_path_buf()).or_default().push(f);
}
}
let mut out = Vec::new();
for (parent, group) in by_parent {
if group.len() < min_files {
continue;
}
let mut m = build_monad(&parent, &group);
if let Some(db) = prior {
// Reusamos id si encontramos Mónada previa con mismo
// path_hint Y mismo centroid_model. Distintas hipótesis
// de modelo no comparten identidad — son objetos
// semánticos distintos, aunque parecidos.
if let Some(existing) = db.monads().find(|prev| {
prev.path_hint.as_deref() == m.path_hint.as_deref()
&& prev.centroid_model == m.centroid_model
}) {
m.id = existing.id;
m.lineage = existing.lineage;
m.created_at_ms = existing.created_at_ms;
m.touch();
}
}
out.push(m);
}
out
}
fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
let label = label_from_path(parent);
let keywords = top_extensions(group, 5);
let lens = pick_lens(group);
let entropy = shannon_entropy_normalized(group);
let summary = build_summary(parent, group, &keywords);
// Centroide vectorial: promedio de los embeddings de los miembros.
// Esto es lo que permite "atracción" determinista de archivos
// nuevos sin tocar Nous.
let member_vecs: Vec<Vec<f32>> = group.iter().map(|f| embed::embed(f).to_vec()).collect();
let centroid = embed::centroid(&member_vecs);
let mut m = MonadManifest::new(label);
m.summary = summary;
m.keywords = keywords;
m.dominant_lens = lens;
m.entropy = entropy;
m.centroid = centroid;
// Taggeamos el centroide con su modelo. attract verifica esto
// antes de comparar para no mezclar pseudo-32d con real-384d.
m.centroid_model = Some(embed::MODEL_ID.to_string());
// path_hint = identidad estable across re-scans para
// hidratación. Display es lossy con UTF-8 inválido pero los
// paths legítimos se imprimen consistentes.
m.path_hint = Some(parent.display().to_string());
m.members = group.iter().map(|f| f.id).collect();
m.touch();
m
}
/// Construye un label legible tomando los últimos hasta 2 componentes
/// del path. Esto desambigua `src/` repetidos en monorepos: en lugar
/// de 5 Mónadas con label "src", quedan "ente-zero/src", "ente-brain/src",
/// etc. Para directorios shallow (root o un nivel), cae al
/// `file_name()` simple.
fn label_from_path(p: &std::path::Path) -> String {
let normals: Vec<&str> = p
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => s.to_str(),
_ => None,
})
.collect();
if normals.is_empty() {
return "unnamed".to_string();
}
let take = normals.len().min(2);
let start = normals.len() - take;
normals[start..].join("/")
}
fn build_summary(parent: &std::path::Path, group: &[&FileEntry], keywords: &[String]) -> String {
let path_str = parent.display();
let n = group.len();
let exts = if keywords.is_empty() {
"(sin extensiones)".to_string()
} else {
keywords.join(", ")
};
format!("{n} archivos en {path_str} (ext: {exts})")
}
/// Top-N extensiones por frecuencia, descendente. Empate por orden alfabético.
fn top_extensions(files: &[&FileEntry], n: usize) -> Vec<String> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for f in files {
if let Some(ext) = &f.extension {
*counts.entry(ext.clone()).or_default() += 1;
}
}
let mut sorted: Vec<_> = counts.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
sorted.into_iter().take(n).map(|(k, _)| k).collect()
}
/// Elige el lente dominante según la extensión más frecuente, con
/// fallback a `shuma-discern` sobre el head del archivo más
/// representativo cuando la extensión no da hint claro (Lens::Grid).
fn pick_lens(files: &[&FileEntry]) -> Lens {
let dominant = top_extensions(files, 1).into_iter().next();
let by_ext = match dominant.as_deref() {
Some("rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "java" | "kt" | "c" | "cpp"
| "cc" | "h" | "hpp" | "rb" | "swift" | "zig") => Lens::Code,
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "tiff" | "heic") => {
Lens::Gallery
}
Some("md" | "markdown" | "rst" | "txt" | "org" | "tex") => Lens::Markdown,
Some("db" | "sqlite" | "sqlite3" | "csv" | "tsv" | "parquet") => Lens::Database,
_ => Lens::Grid,
};
if by_ext != Lens::Grid {
return by_ext;
}
// Fallback: samplear el primer archivo del grupo con shuma-discern.
// Sólo si tiene path real (FileEntry con path absoluto/relativo).
if let Some(first) = files.first() {
if let Some(lens) = discern_lens(&first.path) {
return lens;
}
}
Lens::Grid
}
fn discern_lens(path: &std::path::Path) -> Option<Lens> {
use std::io::Read;
let mut buf = vec![0u8; 4096];
let mut f = std::fs::File::open(path).ok()?;
let n = f.read(&mut buf).ok()?;
buf.truncate(n);
let pipeline = shuma_discern::DiscernPipeline::default_pipeline();
let path_str = path.to_str();
let d = pipeline.discern(
&buf,
&shuma_discern::Hint {
path: path_str,
size_total: None,
},
)?;
match d.lens.as_deref()? {
"code" => Some(Lens::Code),
"gallery" => Some(Lens::Gallery),
"markdown" => Some(Lens::Markdown),
"database" => Some(Lens::Database),
"tree" => Some(Lens::Tree),
_ => None,
}
}
/// Entropía de Shannon normalizada sobre la distribución de extensiones.
/// `0.0` = todos los archivos comparten extensión. `1.0` = uniformly
/// distributed entre `n` extensiones (máx información).
fn shannon_entropy_normalized(files: &[&FileEntry]) -> f32 {
let total = files.len() as f32;
if total <= 1.0 {
return 0.0;
}
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for f in files {
let ext = f.extension.as_deref().unwrap_or("(none)");
*counts.entry(ext.to_string()).or_default() += 1;
}
let entropy: f32 = counts
.values()
.map(|&c| {
let p = c as f32 / total;
-p * p.log2()
})
.sum();
let max_entropy = (counts.len() as f32).log2();
if max_entropy <= 0.0 {
0.0
} else {
(entropy / max_entropy).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chasqui_card::FileId;
use std::path::PathBuf;
use ulid::Ulid;
fn mkfile(path: &str, ext: Option<&str>) -> FileEntry {
FileEntry {
id: FileId::from(Ulid::new()),
path: PathBuf::from(path),
content_hash: None,
size: 100,
mtime_ms: 0,
extension: ext.map(String::from),
}
}
#[test]
fn groups_by_parent_directory() {
let files = vec![
mkfile("/proj/src/a.rs", Some("rs")),
mkfile("/proj/src/b.rs", Some("rs")),
mkfile("/proj/src/c.rs", Some("rs")),
mkfile("/proj/docs/readme.md", Some("md")),
mkfile("/proj/docs/guide.md", Some("md")),
mkfile("/proj/docs/notes.md", Some("md")),
];
let monads = by_directory(&files, 3);
assert_eq!(monads.len(), 2);
let labels: std::collections::BTreeSet<_> = monads.iter().map(|m| &m.label).collect();
// Phase B: labels usan los últimos 2 componentes del path para
// desambiguar (proj/src vs proj/docs en lugar de src vs docs).
assert!(labels.iter().any(|l| l.as_str() == "proj/src"));
assert!(labels.iter().any(|l| l.as_str() == "proj/docs"));
}
#[test]
fn small_groups_not_promoted() {
let files = vec![
mkfile("/proj/single.txt", Some("txt")),
mkfile("/proj/sub/a.txt", Some("txt")),
mkfile("/proj/sub/b.txt", Some("txt")),
mkfile("/proj/sub/c.txt", Some("txt")),
];
// min=3 → /proj/single solo no se promueve, /proj/sub sí.
let monads = by_directory(&files, 3);
assert_eq!(monads.len(), 1);
assert_eq!(monads[0].label, "proj/sub");
}
#[test]
fn label_from_root_only_one_component() {
// Un solo componente normal en el path → no hay "padre" útil.
let p = std::path::Path::new("/onlyone");
assert_eq!(label_from_path(p), "onlyone");
}
#[test]
fn label_from_deep_path_takes_last_two() {
let p = std::path::Path::new("/a/b/c/d/e");
assert_eq!(label_from_path(p), "d/e");
}
#[test]
fn lens_picked_by_dominant_extension() {
let files = vec![
mkfile("/x/a.rs", Some("rs")),
mkfile("/x/b.rs", Some("rs")),
mkfile("/x/c.rs", Some("rs")),
];
let monads = by_directory(&files, 3);
assert_eq!(monads[0].dominant_lens, Lens::Code);
let files = vec![
mkfile("/y/1.png", Some("png")),
mkfile("/y/2.png", Some("png")),
mkfile("/y/3.png", Some("png")),
];
let monads = by_directory(&files, 3);
assert_eq!(monads[0].dominant_lens, Lens::Gallery);
}
#[test]
fn entropy_zero_for_homogeneous() {
let files = vec![
mkfile("/x/a.rs", Some("rs")),
mkfile("/x/b.rs", Some("rs")),
mkfile("/x/c.rs", Some("rs")),
];
let monads = by_directory(&files, 3);
assert_eq!(monads[0].entropy, 0.0);
}
#[test]
fn entropy_high_for_diverse() {
let files = vec![
mkfile("/x/a.rs", Some("rs")),
mkfile("/x/b.md", Some("md")),
mkfile("/x/c.json", Some("json")),
mkfile("/x/d.png", Some("png")),
];
let monads = by_directory(&files, 3);
// 4 extensiones distintas, distribución uniforme → entropy ≈ 1.0
assert!(monads[0].entropy > 0.9, "got {}", monads[0].entropy);
}
#[test]
fn top_extensions_orders_by_freq_then_alpha() {
let files = vec![
mkfile("/x/a.rs", Some("rs")),
mkfile("/x/b.rs", Some("rs")),
mkfile("/x/c.md", Some("md")),
mkfile("/x/d.py", Some("py")),
];
let refs: Vec<&FileEntry> = files.iter().collect();
let top = top_extensions(&refs, 3);
assert_eq!(top, vec!["rs", "md", "py"]);
}
}
+313
View File
@@ -0,0 +1,313 @@
//! DB de Mónadas y archivos. Backend dual:
//!
//! - **Memoria** (default, cache): `BTreeMap<Id, T>` para reads O(log n).
//! - **Persistencia** (opcional): sled-backed write-through. Si se abre
//! con `MonadDb::open(path)`, cada `insert_*` escribe a sled además
//! de la cache. Reads siempre vienen de la cache.
//!
//! Wire format: JSON via serde_json. Los manifestos son chicos y
//! ocasionalmente inspeccionables a mano (`sled-cli`); JSON gana sobre
//! postcard en debuggability.
use std::collections::BTreeMap;
use std::path::Path;
use chasqui_card::{FileEntry, FileId, MonadId, MonadManifest};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MonadDbError {
#[error("sled: {0}")]
Sled(#[from] sled::Error),
#[error("JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("ULID inválido en clave: {0}")]
BadKey(String),
}
const TREE_FILES: &str = "files";
const TREE_MONADS: &str = "monads";
/// Store de Mónadas + archivos. Cache en memoria + persistencia
/// opcional sled.
pub struct MonadDb {
files: BTreeMap<FileId, FileEntry>,
monads: BTreeMap<MonadId, MonadManifest>,
persistence: Option<sled::Db>,
}
impl Default for MonadDb {
fn default() -> Self {
Self::new()
}
}
impl MonadDb {
/// Store en memoria pura (sin persistencia). El estado se pierde al salir.
pub fn new() -> Self {
Self {
files: BTreeMap::new(),
monads: BTreeMap::new(),
persistence: None,
}
}
/// Abre (o crea) un store sled-backed en `path`. Carga el contenido
/// existente a la cache antes de devolver.
pub fn open(path: impl AsRef<Path>) -> Result<Self, MonadDbError> {
let db = sled::open(path)?;
let mut files = BTreeMap::new();
let mut monads = BTreeMap::new();
let files_tree = db.open_tree(TREE_FILES)?;
for kv in files_tree.iter() {
let (k, v) = kv?;
let id = decode_key(&k)?;
let entry: FileEntry = serde_json::from_slice(&v)?;
files.insert(id, entry);
}
let monads_tree = db.open_tree(TREE_MONADS)?;
for kv in monads_tree.iter() {
let (k, v) = kv?;
let id = decode_key(&k)?;
let monad: MonadManifest = serde_json::from_slice(&v)?;
monads.insert(id, monad);
}
Ok(Self {
files,
monads,
persistence: Some(db),
})
}
/// `true` si tiene backend persistente.
pub fn is_persistent(&self) -> bool {
self.persistence.is_some()
}
// ---- Files ----
pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> {
if let Some(db) = &self.persistence {
// Write-through: si falla el persist, lo logeamos pero la
// memoria queda actualizada. Filosofía: cache nunca miente
// sobre el último estado conocido en este proceso.
if let Err(e) = persist_file(db, &file) {
eprintln!("[MonadDb] persist file falló: {e}");
}
}
self.files.insert(file.id, file)
}
pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
for f in files {
self.insert_file(f);
}
}
pub fn file(&self, id: FileId) -> Option<&FileEntry> {
self.files.get(&id)
}
pub fn files(&self) -> impl Iterator<Item = &FileEntry> + '_ {
self.files.values()
}
pub fn file_count(&self) -> usize {
self.files.len()
}
// ---- Monads ----
pub fn insert_monad(&mut self, monad: MonadManifest) -> Option<MonadManifest> {
if let Some(db) = &self.persistence {
if let Err(e) = persist_monad(db, &monad) {
eprintln!("[MonadDb] persist monad falló: {e}");
}
}
self.monads.insert(monad.id, monad)
}
pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) {
// Si hay persistencia, limpiar tree antes de insertar.
if let Some(db) = &self.persistence {
if let Ok(tree) = db.open_tree(TREE_MONADS) {
let _ = tree.clear();
}
}
self.monads.clear();
for m in monads {
self.insert_monad(m);
}
}
pub fn monad(&self, id: MonadId) -> Option<&MonadManifest> {
self.monads.get(&id)
}
pub fn monads(&self) -> impl Iterator<Item = &MonadManifest> + '_ {
self.monads.values()
}
pub fn monad_count(&self) -> usize {
self.monads.len()
}
/// Resuelve los archivos miembros de una Mónada como referencias.
/// Skipea silenciosamente IDs que ya no estén en la tabla `files`.
pub fn resolve_members(&self, monad_id: MonadId) -> Vec<&FileEntry> {
match self.monads.get(&monad_id) {
Some(m) => m.members.iter().filter_map(|id| self.files.get(id)).collect(),
None => Vec::new(),
}
}
}
fn persist_file(db: &sled::Db, f: &FileEntry) -> Result<(), MonadDbError> {
let tree = db.open_tree(TREE_FILES)?;
let key = f.id.to_string();
let val = serde_json::to_vec(f)?;
tree.insert(key.as_bytes(), val)?;
Ok(())
}
fn persist_monad(db: &sled::Db, m: &MonadManifest) -> Result<(), MonadDbError> {
let tree = db.open_tree(TREE_MONADS)?;
let key = m.id.to_string();
let val = serde_json::to_vec(m)?;
tree.insert(key.as_bytes(), val)?;
Ok(())
}
fn decode_key(k: &[u8]) -> Result<ulid::Ulid, MonadDbError> {
let s = std::str::from_utf8(k).map_err(|_| MonadDbError::BadKey(format!("{:?}", k)))?;
ulid::Ulid::from_string(s).map_err(|_| MonadDbError::BadKey(s.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use chasqui_card::Lens;
use ulid::Ulid;
fn mk_file(path: &str) -> FileEntry {
FileEntry {
id: FileId::from(Ulid::new()),
path: std::path::PathBuf::from(path),
content_hash: None,
size: 100,
mtime_ms: 0,
extension: Some("rs".into()),
}
}
#[test]
fn ingest_and_lookup() {
let mut db = MonadDb::new();
let f1 = mk_file("/a/x.rs");
let f2 = mk_file("/a/y.rs");
let id1 = f1.id;
db.ingest_files(vec![f1, f2]);
assert_eq!(db.file_count(), 2);
assert!(db.file(id1).is_some());
assert!(!db.is_persistent());
}
#[test]
fn resolve_members_filters_missing() {
let mut db = MonadDb::new();
let f1 = mk_file("/x/a.rs");
let id1 = f1.id;
db.insert_file(f1);
let mut m = MonadManifest::new("test");
m.members.insert(id1);
m.members.insert(FileId::from(Ulid::new())); // miembro fantasma
m.dominant_lens = Lens::Code;
m.touch();
let mid = m.id;
db.insert_monad(m);
let resolved = db.resolve_members(mid);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].id, id1);
}
#[test]
fn replace_monads_clears_old() {
let mut db = MonadDb::new();
let mut m1 = MonadManifest::new("a");
m1.members.insert(FileId::from(Ulid::new()));
m1.touch();
db.insert_monad(m1);
assert_eq!(db.monad_count(), 1);
let mut m2 = MonadManifest::new("b");
m2.members.insert(FileId::from(Ulid::new()));
m2.touch();
db.replace_monads(vec![m2]);
assert_eq!(db.monad_count(), 1);
assert!(db.monads().next().unwrap().label == "b");
}
#[test]
fn persistence_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let dbpath = tmp.path().join("monads.sled");
// Escribimos algunos datos
{
let mut db = MonadDb::open(&dbpath).expect("open");
assert!(db.is_persistent());
let f = mk_file("/persist/a.rs");
let fid = f.id;
db.insert_file(f);
let mut m = MonadManifest::new("persist-test");
m.members.insert(fid);
m.dominant_lens = Lens::Code;
m.touch();
db.insert_monad(m);
}
// Reabrimos y verificamos que están
let db = MonadDb::open(&dbpath).expect("reopen");
assert_eq!(db.file_count(), 1);
assert_eq!(db.monad_count(), 1);
let m = db.monads().next().unwrap();
assert_eq!(m.label, "persist-test");
assert_eq!(m.cardinality, 1);
}
#[test]
fn replace_monads_purges_persistent_tree() {
let tmp = tempfile::tempdir().unwrap();
let dbpath = tmp.path().join("replace.sled");
{
let mut db = MonadDb::open(&dbpath).unwrap();
let mut m1 = MonadManifest::new("old");
m1.members.insert(FileId::from(Ulid::new()));
m1.touch();
db.insert_monad(m1);
}
// Reabrir, replace, verificar
{
let mut db = MonadDb::open(&dbpath).unwrap();
assert_eq!(db.monad_count(), 1);
let mut m2 = MonadManifest::new("new");
m2.members.insert(FileId::from(Ulid::new()));
m2.touch();
db.replace_monads(vec![m2]);
assert_eq!(db.monad_count(), 1);
}
// Tercera apertura: sólo "new" sobrevive
let db = MonadDb::open(&dbpath).unwrap();
assert_eq!(db.monad_count(), 1);
assert_eq!(db.monads().next().unwrap().label, "new");
}
}
+298
View File
@@ -0,0 +1,298 @@
//! Pseudo-embeddings de archivos: vectores deterministas derivados de
//! metadatos (sin LLM).
//!
//! Implementan el "imán semántico" matemático que el diseño de Kairos
//! pide: cada archivo tiene un vector, cada Mónada tiene un centroide,
//! y un archivo nuevo se "pega" a la Mónada cuyo centroide está más
//! cerca (cosine similarity).
//!
//! No reemplaza embeddings reales (text-embedding de un LLM); sirve para:
//! - Bootstrapping sin Nous corriendo.
//! - Mock determinístico en `BRAHMAN_BROKER_CONTEXT=test`.
//! - Cohesión visual por path/extension (dos `.rs` en `src/` quedan
//! muy juntos en el espacio vectorial).
//!
//! ## Forma del vector ([`EMBED_DIM`]=32, normalizado)
//!
//! - dims 0..8: `blake3(extension)` → identidad de tipo
//! - dims 8..16: `blake3(parent_dir)` → identidad de contenedor
//! - dims 16..24: `blake3(file_stem)` → identidad léxica del archivo
//! - dims 24..28: tamaño (log scale + flags binarios)
//! - dims 28..32: mtime (escala día + features cíclicas)
//!
//! ## Propiedades empíricas
//!
//! - Mismo dir + misma ext → similitud > 0.7 (alta cohesión).
//! - Mismo dir + ext distinta → similitud ~ 0.5.
//! - Dirs distintos + misma ext → similitud ~ 0.5.
//! - Sin parecido → similitud < 0.3.
use chasqui_card::{FileEntry, MonadId, MonadManifest};
/// Dimensión del vector embedding.
pub const EMBED_DIM: usize = 32;
/// Identificador del modelo que produce este embedding. Se usa para
/// taggear `MonadManifest.centroid_model`: los consumidores comparan
/// este string contra el suyo antes de hacer cosine similarity.
/// Mezclar centroides de distinto MODEL_ID corrompe scores
/// silenciosamente (dimensiones distintas, semántica distinta).
pub const MODEL_ID: &str = "chasqui-pseudo-32d";
/// Computa el embedding de un archivo. Determinístico: misma input
/// → mismo vector. El vector queda L2-normalizado.
pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] {
let mut v = [0.0f32; EMBED_DIM];
// dims 0..8: extension hash
fill_from_hash(&mut v[0..8], file.extension.as_deref().unwrap_or(""));
// dims 8..16: parent dir name hash
let parent = file
.path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("");
fill_from_hash(&mut v[8..16], parent);
// dims 16..24: file stem hash (sin extensión)
let stem = file
.path
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("");
fill_from_hash(&mut v[16..24], stem);
// dims 24..28: tamaño (centrado en 0 para que dot products entre
// archivos de tamaño diferente sumen 0 en expectativa).
let log_size = (file.size.max(1) as f32).log10();
v[24] = ((log_size / 15.0).clamp(0.0, 1.0) - 0.5) * 2.0; // [-1, 1]
v[25] = (log_size.fract() - 0.5) * 2.0;
v[26] = if file.size >= 1_048_576 { 1.0 } else { -1.0 }; // ≥1MiB flag
v[27] = if file.size <= 256 { 1.0 } else { -1.0 }; // ≤256B flag
// dims 28..32: mtime — escala día + cíclicas (centradas).
let day = file.mtime_ms / (86_400 * 1000);
v[28] = (((day as f32) / 30_000.0).clamp(0.0, 1.0) - 0.5) * 2.0;
v[29] = ((day % 365) as f32 / 365.0 - 0.5) * 2.0;
v[30] = ((day % 30) as f32 / 30.0 - 0.5) * 2.0;
v[31] = ((day % 7) as f32 / 7.0 - 0.5) * 2.0;
normalize(&mut v);
v
}
/// Fill `out` con bytes del hash blake3 de `input`, centrados en [-1, 1].
/// El centrado es crítico: bytes uniformes en [0,1] tienen media 0.5,
/// así dos vectores hash distintos (de strings no relacionados) tendrían
/// expected cosine similarity ≈ 0.75 (espuriamente alto). Centrarlos en
/// [-1, 1] hace que la expectativa sea ≈ 0 — propiedad necesaria para
/// que cosine similarity sea una métrica útil de afinidad.
fn fill_from_hash(out: &mut [f32], input: &str) {
let h = blake3::hash(input.as_bytes());
let bytes = h.as_bytes();
for (i, slot) in out.iter_mut().enumerate() {
*slot = (bytes[i] as f32 - 127.5) / 127.5;
}
}
/// L2-normaliza un vector in-place. Vectores con norma 0 quedan en 0.
fn normalize(v: &mut [f32]) {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for x in v.iter_mut() {
*x /= norm;
}
}
}
/// Cosine similarity entre dos vectores. Asume ambos L2-normalizados
/// (en cuyo caso `dot product == cosine similarity`). Si las longitudes
/// no coinciden, devuelve 0.
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
/// Centroide de un set de vectores. Promedio dim-por-dim seguido de
/// L2-normalización. El resultado es un vector unidad apto para
/// comparar con miembros nuevos vía cosine similarity.
pub fn centroid(vectors: &[Vec<f32>]) -> Vec<f32> {
if vectors.is_empty() {
return Vec::new();
}
let dim = vectors[0].len();
let mut c = vec![0.0f32; dim];
for v in vectors {
if v.len() != dim {
continue;
}
for (i, x) in v.iter().enumerate() {
c[i] += x;
}
}
let n = vectors.len() as f32;
for x in c.iter_mut() {
*x /= n;
}
normalize(&mut c);
c
}
/// Cohesión interna: media de cosine similarity de cada miembro contra
/// el centroide. Alta cohesión = Mónada compacta. Baja = bifurcable.
pub fn cohesion(centroid: &[f32], member_vectors: &[Vec<f32>]) -> f32 {
if member_vectors.is_empty() || centroid.is_empty() {
return 0.0;
}
let sum: f32 = member_vectors
.iter()
.map(|v| cosine_similarity(centroid, v))
.sum();
sum / member_vectors.len() as f32
}
/// Score de atracción de un archivo nuevo a una Mónada existente:
/// cosine similarity de su embedding contra el centroide de la Mónada.
/// Mayor score = mayor afinidad.
pub fn attraction_score(file_vec: &[f32], monad: &MonadManifest) -> f32 {
if monad.centroid.is_empty() {
return 0.0;
}
cosine_similarity(file_vec, &monad.centroid)
}
/// Encuentra la Mónada con mayor afinidad a un archivo. Devuelve
/// `(MonadId, score)` o `None` si ninguna tiene centroide.
pub fn best_attraction<'a, I>(file_vec: &[f32], monads: I) -> Option<(MonadId, f32)>
where
I: IntoIterator<Item = &'a MonadManifest>,
{
monads
.into_iter()
.filter(|m| !m.centroid.is_empty())
.map(|m| (m.id, attraction_score(file_vec, m)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
}
/// Umbral por defecto para "se pega": si el score es ≥ esto, el
/// archivo se asigna automáticamente. Ajustable por el caller.
pub const DEFAULT_ATTRACTION_THRESHOLD: f32 = 0.7;
#[cfg(test)]
mod tests {
use super::*;
use chasqui_card::FileId;
use std::path::PathBuf;
use ulid::Ulid;
fn mk(path: &str, ext: Option<&str>, size: u64) -> FileEntry {
FileEntry {
id: FileId::from(Ulid::new()),
path: PathBuf::from(path),
content_hash: None,
size,
mtime_ms: 1_700_000_000_000, // fixed para que mtime no domine
extension: ext.map(String::from),
}
}
#[test]
fn embed_is_deterministic() {
let a = mk("/x/foo.rs", Some("rs"), 1024);
let b = mk("/x/foo.rs", Some("rs"), 1024);
let va = embed(&a);
let vb = embed(&b);
// Mismos metadatos → mismo vector (los IDs no entran al embedding).
assert_eq!(va, vb);
}
#[test]
fn embed_is_unit_normalized() {
let f = mk("/x/foo.rs", Some("rs"), 1024);
let v = embed(&f);
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
}
#[test]
fn same_dir_same_ext_high_similarity() {
let a = embed(&mk("/proj/src/a.rs", Some("rs"), 1000));
let b = embed(&mk("/proj/src/b.rs", Some("rs"), 1100));
let sim = cosine_similarity(&a, &b);
assert!(sim > 0.7, "esperaba sim > 0.7, fue {sim}");
}
#[test]
fn unrelated_files_low_similarity() {
let a = embed(&mk("/proj/src/main.rs", Some("rs"), 1000));
let b = embed(&mk("/photos/2024/sunset.jpg", Some("jpg"), 5_000_000));
let sim = cosine_similarity(&a, &b);
assert!(sim < 0.5, "esperaba sim < 0.5, fue {sim}");
}
#[test]
fn centroid_is_unit_and_close_to_members() {
let v1 = embed(&mk("/x/a.rs", Some("rs"), 1000));
let v2 = embed(&mk("/x/b.rs", Some("rs"), 1100));
let v3 = embed(&mk("/x/c.rs", Some("rs"), 1200));
let c = centroid(&[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
// Norma unitaria.
let norm: f32 = c.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
// Cohesión alta porque los miembros son similares.
let cohesion = cohesion(&c, &[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
assert!(cohesion > 0.9, "cohesion={cohesion}");
}
#[test]
fn attraction_picks_correct_monad() {
// Construimos dos Mónadas: una de Rust, otra de imágenes.
let rust_files = vec![
embed(&mk("/proj/src/a.rs", Some("rs"), 1000)).to_vec(),
embed(&mk("/proj/src/b.rs", Some("rs"), 1100)).to_vec(),
];
let img_files = vec![
embed(&mk("/photos/p1.jpg", Some("jpg"), 5_000_000)).to_vec(),
embed(&mk("/photos/p2.jpg", Some("jpg"), 4_000_000)).to_vec(),
];
let mut rust_monad = MonadManifest::new("rust");
rust_monad.members.insert(FileId::from(Ulid::new()));
rust_monad.touch();
rust_monad.centroid = centroid(&rust_files);
let mut img_monad = MonadManifest::new("photos");
img_monad.members.insert(FileId::from(Ulid::new()));
img_monad.touch();
img_monad.centroid = centroid(&img_files);
// Un archivo .rs nuevo en /proj/src debe atraerse a la Mónada Rust.
let new_rs = embed(&mk("/proj/src/new.rs", Some("rs"), 1500));
let (best_id, _score) = best_attraction(&new_rs, [&rust_monad, &img_monad].into_iter())
.expect("best match");
assert_eq!(best_id, rust_monad.id);
// Y al revés.
let new_jpg = embed(&mk("/photos/new.jpg", Some("jpg"), 6_000_000));
let (best_id, _score) = best_attraction(&new_jpg, [&rust_monad, &img_monad].into_iter())
.expect("best match");
assert_eq!(best_id, img_monad.id);
}
#[test]
fn empty_centroid_skipped_in_attraction() {
let mut m = MonadManifest::new("empty");
m.members.insert(FileId::from(Ulid::new()));
m.touch();
// m.centroid queda vacío
let v = embed(&mk("/x/y.rs", Some("rs"), 100));
assert!(best_attraction(&v, [&m].into_iter()).is_none());
}
}
@@ -0,0 +1,242 @@
//! Listener Unix-socket que sirve [`chasqui_card::query::QueryRequest`].
//!
//! El daemon `chasqui` lo monta para que cualquier consumer (UI, CLI,
//! otro módulo) pueda preguntarle por sus Mónadas sin pasar por
//! brahman-admin. El path del socket viaja en el `Card.service_socket`
//! del engine; el broker brahman lo enseña vía MatchEvent::Available
//! cuando un consumer declara `flow.input = monad-list:json`.
//!
//! Wire: line-delimited JSON, single-shot por conexión. Mismo patrón
//! que `chasqui-nous` (mock/real ↔ chasqui-core), reutilizado.
//!
//! Threading: un thread dedicado, blocking I/O. No vale la pena traer
//! tokio acá — la frecuencia esperada es muy baja (UI poll cada 2s)
//! y el handler es trivial (lock db → snapshot → write).
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use chasqui_card::query::{
EngineInfo, ErrorResponse, ListMonadsResponse, MonadView, QueryRequest,
};
use chasqui_card::ulid::Ulid;
use crate::db::MonadDb;
/// Configuración del listener.
pub struct ListenerConfig {
pub socket_path: PathBuf,
pub engine_id: Ulid,
pub engine_label: String,
/// Path del directorio que el daemon está observando, para incluir
/// en `EngineInfo.watching`. `None` si el daemon no observa nada.
pub watching: Option<PathBuf>,
}
/// Bind del socket + spawn de un thread con accept loop. Devuelve el
/// path final (útil para confirmar) y un `JoinHandle` para shutdown
/// explícito (drop = thread sigue, listener queda).
///
/// Si el socket ya existe (sesión anterior crasheada), se intenta
/// removerlo antes del bind. Errores de bind se propagan al caller.
pub fn spawn_listener(
config: ListenerConfig,
db: Arc<Mutex<MonadDb>>,
) -> std::io::Result<std::thread::JoinHandle<()>> {
if config.socket_path.exists() {
let _ = std::fs::remove_file(&config.socket_path);
}
if let Some(parent) = config.socket_path.parent() {
std::fs::create_dir_all(parent)?;
}
let listener = UnixListener::bind(&config.socket_path)?;
let handle = std::thread::Builder::new()
.name("chasqui-engine-listener".into())
.spawn(move || {
for conn in listener.incoming() {
match conn {
Ok(stream) => {
// Handler sincrónico inline. La frecuencia
// esperada (UI poll cada N segundos) no
// amerita spawn-per-connection; si en el
// futuro hay carga, agregar un threadpool.
if let Err(e) = handle_conn(stream, &db, &config) {
eprintln!("[engine-socket] conn falló: {e}");
}
}
Err(e) => {
eprintln!("[engine-socket] accept falló: {e}");
}
}
}
})?;
Ok(handle)
}
fn handle_conn(
mut stream: UnixStream,
db: &Arc<Mutex<MonadDb>>,
config: &ListenerConfig,
) -> std::io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
return Ok(());
}
let resp_bytes = match serde_json::from_str::<QueryRequest>(line.trim()) {
Ok(QueryRequest::ListMonads) => match handle_list_monads(db, config) {
Ok(json) => json,
Err(e) => encode_error(format!("list_monads falló: {e}")),
},
Err(e) => encode_error(format!("JSON inválido: {e}")),
};
stream.write_all(resp_bytes.as_bytes())?;
stream.write_all(b"\n")?;
stream.flush()?;
let _ = stream.shutdown(std::net::Shutdown::Both);
Ok(())
}
fn handle_list_monads(
db: &Arc<Mutex<MonadDb>>,
config: &ListenerConfig,
) -> Result<String, String> {
let db_lock = db.lock().map_err(|_| "mutex envenenado".to_string())?;
let monads: Vec<MonadView> = db_lock.monads().map(MonadView::from_manifest).collect();
let resp = ListMonadsResponse {
engine: EngineInfo {
id: config.engine_id,
label: config.engine_label.clone(),
watching: config.watching.as_ref().map(|p| p.display().to_string()),
},
monads,
};
serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
}
fn encode_error(msg: String) -> String {
let err = ErrorResponse { error: msg };
serde_json::to_string(&err).unwrap_or_else(|_| "{\"error\":\"encode\"}".into())
}
// El cliente blocking vive en `chasqui_card::query::client` — junto a
// los wire types — para que un consumer pueda hablar con el daemon
// importando sólo `chasqui-card`, sin arrastrar el peso de
// `chasqui-core` (scanner / db / sled / notify / walkdir / blake3).
#[cfg(test)]
mod tests {
use super::*;
use crate::db::MonadDb;
use chasqui_card::query::client as query_client;
use chasqui_card::MonadManifest;
use std::time::Duration;
fn fresh_socket_path(name: &str) -> PathBuf {
let dir = std::env::temp_dir();
let unique = format!("{}-{}-{}.sock", name, std::process::id(), Ulid::new());
dir.join(unique)
}
#[test]
fn list_monads_roundtrip_empty() {
let socket = fresh_socket_path("chasqui-engine-test");
let db = Arc::new(Mutex::new(MonadDb::new()));
let engine_id = Ulid::new();
let _h = spawn_listener(
ListenerConfig {
socket_path: socket.clone(),
engine_id,
engine_label: "test-engine".into(),
watching: Some(PathBuf::from("/tmp/x")),
},
db.clone(),
)
.unwrap();
// Pequeña espera para que el bind se asiente (en práctica el
// socket existe inmediatamente tras el bind, pero algunos FS
// necesitan un tick). Si esto resulta flaky, agregar un loop
// de wait_for(socket.exists()).
std::thread::sleep(Duration::from_millis(50));
let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap();
assert_eq!(resp.engine.id, engine_id);
assert_eq!(resp.engine.label, "test-engine");
assert_eq!(resp.engine.watching.as_deref(), Some("/tmp/x"));
assert!(resp.monads.is_empty());
let _ = std::fs::remove_file(&socket);
}
#[test]
fn list_monads_returns_views() {
let socket = fresh_socket_path("chasqui-engine-test-views");
let db = Arc::new(Mutex::new(MonadDb::new()));
let m1 = MonadManifest::new("alpha");
let m2 = MonadManifest::new("beta");
{
let mut g = db.lock().unwrap();
g.replace_monads(vec![m1.clone(), m2.clone()]);
}
let _h = spawn_listener(
ListenerConfig {
socket_path: socket.clone(),
engine_id: Ulid::new(),
engine_label: "test".into(),
watching: None,
},
db.clone(),
)
.unwrap();
std::thread::sleep(Duration::from_millis(50));
let resp = query_client::list_monads(&socket, Duration::from_secs(2)).unwrap();
assert_eq!(resp.monads.len(), 2);
let labels: Vec<_> = resp.monads.iter().map(|m| m.label.as_str()).collect();
assert!(labels.contains(&"alpha"));
assert!(labels.contains(&"beta"));
let _ = std::fs::remove_file(&socket);
}
#[test]
fn invalid_request_returns_error_response() {
let socket = fresh_socket_path("chasqui-engine-test-bad");
let db = Arc::new(Mutex::new(MonadDb::new()));
let _h = spawn_listener(
ListenerConfig {
socket_path: socket.clone(),
engine_id: Ulid::new(),
engine_label: "test".into(),
watching: None,
},
db.clone(),
)
.unwrap();
std::thread::sleep(Duration::from_millis(50));
// Bypass del cliente tipado: mandamos JSON inválido a mano.
use std::io::{BufRead, BufReader, Write};
let mut stream = UnixStream::connect(&socket).unwrap();
stream.write_all(b"not json\n").unwrap();
stream.flush().unwrap();
let mut reader = BufReader::new(stream);
let mut response = String::new();
reader.read_line(&mut response).unwrap();
assert!(
response.contains("\"error\""),
"esperaba ErrorResponse, got: {response}"
);
let _ = std::fs::remove_file(&socket);
}
}
+34
View File
@@ -0,0 +1,34 @@
//! `chasqui-core` — el explorador de Mónadas.
//!
//! Implementa la pipeline determinista descrita en el diseño de Kairos:
//!
//! 1. [`scanner`]: recorre directorios y emite [`FileEntry`] (sin tocar
//! contenido en Phase 0 — sólo metadatos).
//! 2. [`cluster`]: agrupa archivos en [`MonadManifest`] usando
//! heurísticas (parent dir + extensión dominante). 0 LLM.
//! 3. [`db`]: store en memoria con índices files↔monads.
//!
//! Pipeline:
//! ```text
//! scan_directory(path)
//! → Vec<FileEntry>
//! → cluster::by_directory(min_files=N)
//! → Vec<MonadManifest>
//! → MonadDb::ingest(...)
//! ```
//!
//! Lo importante: en este crate no hay IA, no hay embeddings. Es la
//! capa determinista que cubre el 90% de los casos. Los embeddings
//! (`Phase C`) y Nous (`Phase D`) se enchufan después como módulos
//! separados que producen flows brahman.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod cluster;
pub mod db;
pub mod embed;
pub mod engine_socket;
pub mod scanner;
pub use chasqui_card::*;
+178
View File
@@ -0,0 +1,178 @@
//! Recorrido de directorios. Sólo metadatos — no lee contenido.
//!
//! Usa `walkdir` (sequential). Para árboles muy grandes considerar
//! migrar a `jwalk` (paralelo); por ahora la simplicidad gana.
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use chasqui_card::{FileEntry, FileId};
use thiserror::Error;
use ulid::Ulid;
use walkdir::WalkDir;
#[derive(Debug, Error)]
pub enum ScanError {
#[error("ruta no existe: {0}")]
NotFound(PathBuf),
#[error("no se pudo leer: {0}")]
Walk(String),
}
/// Configuración del scan.
#[derive(Debug, Clone)]
pub struct ScanConfig {
/// Profundidad máxima (None = ilimitada).
pub max_depth: Option<usize>,
/// Sigue symlinks (default: false, evita ciclos).
pub follow_links: bool,
/// Ignora archivos ocultos (.dotfiles).
pub skip_hidden: bool,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
max_depth: None,
follow_links: false,
skip_hidden: true,
}
}
}
/// Recorre `root` y devuelve un `FileEntry` por cada archivo regular.
/// Errores de permisos en sub-paths se ignoran silenciosamente.
pub fn scan_directory(root: &Path, config: &ScanConfig) -> Result<Vec<FileEntry>, ScanError> {
if !root.exists() {
return Err(ScanError::NotFound(root.to_path_buf()));
}
let mut walker = WalkDir::new(root).follow_links(config.follow_links);
if let Some(d) = config.max_depth {
walker = walker.max_depth(d);
}
let mut entries = Vec::new();
for entry_result in walker {
let entry = match entry_result {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_file() {
continue;
}
if config.skip_hidden && is_hidden(entry.path()) {
continue;
}
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let mtime_ms = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let extension = entry
.path()
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase());
entries.push(FileEntry {
id: FileId::from(Ulid::new()),
path: entry.path().to_path_buf(),
content_hash: None,
size: metadata.len(),
mtime_ms,
extension,
});
}
Ok(entries)
}
/// `true` si alguno de los componentes del path empieza con `.`.
/// Excluye el primer componente (root) para no descartar el directorio raíz
/// si el usuario apuntó a un dotfile-dir explícito.
fn is_hidden(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
#[test]
fn scans_basic_tree() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("a.rs"), "fn main(){}");
write(&root.join("b.rs"), "fn b(){}");
write(&root.join("data/x.json"), "{}");
write(&root.join("data/y.json"), "{}");
let files = scan_directory(root, &ScanConfig::default()).unwrap();
assert_eq!(files.len(), 4);
let exts: std::collections::BTreeSet<_> = files
.iter()
.filter_map(|f| f.extension.clone())
.collect();
assert!(exts.contains("rs"));
assert!(exts.contains("json"));
}
#[test]
fn skips_hidden_by_default() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("visible.txt"), "x");
write(&root.join(".hidden"), "x");
let files = scan_directory(root, &ScanConfig::default()).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].path.ends_with("visible.txt"));
}
#[test]
fn missing_root_errors() {
let p = std::path::Path::new("/nonexistent-12345-abc");
assert!(matches!(
scan_directory(p, &ScanConfig::default()),
Err(ScanError::NotFound(_))
));
}
#[test]
fn max_depth_limits() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write(&root.join("top.txt"), "x");
write(&root.join("a/b/deep.txt"), "x");
let cfg = ScanConfig {
max_depth: Some(1),
..Default::default()
};
let files = scan_directory(root, &cfg).unwrap();
// max_depth=1 incluye archivos en root pero no anidados profundos.
let names: Vec<_> = files
.iter()
.filter_map(|f| f.path.file_name().and_then(|s| s.to_str()))
.map(String::from)
.collect();
assert!(names.contains(&"top.txt".to_string()));
assert!(!names.contains(&"deep.txt".to_string()));
}
}