feat(nouser): Phase A — mecanismo determinista de Mónadas
Primer trozo de Nouser/Kairos: explorador de Mónadas como agrupaciones
semánticas sobre el filesystem, sin tocar IA todavía. Cubre el 90% de
los casos con heurísticas puras.
Crates nuevos:
crates/modules/nouser/card:
- MonadManifest: la Tarjeta de Presentación de una Mónada. Espejo
conceptual de brahman::Card pero para datos: id (Ulid), label,
summary, centroid (vacío en Phase A), keywords, cardinality, entropy
[0,1], dominant_lens (Grid|Code|Gallery|Database|Markdown|Tree),
pins, members, timestamps, extensions (forward-compat).
- Diferencia explícita en docs: brahman::Card describe entidades
runtime con payload/soma/supervision; MonadManifest describe una
agrupación de datos sin proceso atrás.
- Validación: schema_version, label no vacío, entropy en rango,
cardinality consistente con members.len().
- 6 tests (validación + JSON roundtrip).
crates/modules/nouser/core:
- scanner::scan_directory: walkdir → Vec<FileEntry> con metadatos.
Skipea hidden por default; configurable max_depth y follow_links.
- cluster::by_directory: agrupa archivos por parent dir, mínimo 3
para promover a Mónada (configurable). Computa keywords (top-N
extensiones por freq + alfabético), elige Lens dominante por
extensión más frecuente, entropía de Shannon normalizada.
- db::MonadDb: store en memoria con índices BTreeMap.
resolve_members filtra IDs huérfanos.
- bin nouser con subcomandos scan, show, json. Env var
NOUSER_MIN_FILES para el threshold.
- 13 tests (4 scanner + 6 cluster + 3 db).
Demo end-to-end:
$ nouser scan crates
scan: 255 archivos en crates, 19 mónadas (min_files=3)
[01KR4C13] src card=12 ent=0.00 lens=Code keywords: rs
[01KR4C13] tests card=14 ent=0.00 lens=Code keywords: rs
[01KR4C13] fixtures card=5 ent=0.00 lens=Grid keywords: rhai
Pendientes (anotados en CHANGELOG, no urgentes):
- Phase B: bin nouser daemon que sidecarea a brahman-init.
- Phase C: pseudo-embeddings de metadatos + atracción por centroide.
- Phase D: módulo nouser-nous para el LLM real, swappable por
priority_contexts (mock-nous en test, real-nous en prod).
- Polish: labels con 2-3 componentes del path.
cargo check --workspace: 0 errores, 0 warnings.
Tests acumulados: 58.
CHANGELOG.md actualizado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7bdc26e61a68bc0a878122ce6659f0020a56af4e · por Sergio · 2026-05-08T18:03:49Z (hace 5 meses) · padre bbb9a9d2f · ver el árbol en este commit
Delta semántico · 75
crates/modules/nouser/card/src/lib.rs
crates/modules/nouser/core/src/bin/nouser.rs
crates/modules/nouser/core/src/cluster.rs
crates/modules/nouser/core/src/db.rs
crates/modules/nouser/core/src/lib.rs
crates/modules/nouser/core/src/scanner.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd4189e..cd16c20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,74 @@ ratio/diff ver `git show <sha>`.
## 2026-05-08
+### feat(nouser): Phase A — mecanismo determinista de Mónadas
+Primer trozo del módulo Nouser (Kairos): explorador de Mónadas como
+"imanes semánticos" sobre el filesystem. Phase A cubre el 90% de los
+casos sin tocar IA — sólo metadatos y heurísticas.
+
+Crates nuevos:
+
+- `crates/modules/nouser/card`: `MonadManifest` (la Tarjeta de
+ Presentación de una Mónada — espejo conceptual de `brahman::Card`
+ pero para datos, no para procesos runtime). Campos: id (Ulid),
+ label, summary, centroid (vacío en Phase A), keywords, cardinality,
+ entropy [0,1], dominant_lens, pins, members, timestamps,
+ extensions (forward-compat). 6 tests de validación + JSON roundtrip.
+- `crates/modules/nouser/core`: pipeline determinista.
+ - `scanner`: walkdir → `Vec<FileEntry>` con metadatos (path, size,
+ mtime, extension). Skipea hidden por default. Configurable max
+ depth y follow_links.
+ - `cluster::by_directory`: agrupa por parent dir, mínimo 3 archivos
+ para promover a Mónada (configurable). Calcula keywords (top-N
+ extensiones por frecuencia + alfabético), elige `Lens` dominante
+ (Code/Gallery/Markdown/Database/Grid) según extensión más
+ frecuente, computa entropía de Shannon normalizada [0,1].
+ - `db`: `MonadDb` en memoria con índices BTreeMap files/monads y
+ `resolve_members(monad_id)` que filtra IDs huérfanos. Phase B
+ traerá persistencia.
+ - bin `nouser`: subcomandos `scan <dir>`, `show <dir> <prefix>`,
+ `json <dir>`. Env var `NOUSER_MIN_FILES` para tunear el threshold.
+ - 13 tests (4 scanner + 6 cluster + 3 db).
+
+Demo end-to-end:
+
+ $ nouser scan crates
+ scan: 255 archivos en crates, 19 mónadas (min_files=3)
+ [01KR4C13] src card=12 ent=0.00 lens=Code
+ keywords: rs
+ [01KR4C13] tests card=14 ent=0.00 lens=Code
+ keywords: rs
+ [01KR4C13] fixtures card=5 ent=0.00 lens=Grid
+ keywords: rhai
+ ...
+
+ $ nouser show crates 01KR4C
+ Monad 01KR4C1370DVF6NMTW6SECNXAF
+ label: src
+ summary: 4 archivos en crates/modules/nouser/core/src (ext: rs)
+ cardinality: 4
+ entropy: 0.0000
+ lens: Code
+ members (4):
+ 4132 bytes crates/modules/nouser/core/src/db.rs
+ ...
+
+Pendientes para próximas fases (anotados, no urgentes):
+- **Phase B**: bin `nouser daemon` que sidecarea a brahman-init
+ declarando flows (`scan-request:json` → `monad-update:json`).
+- **Phase C**: pseudo-embeddings deterministas (hash de path/ext/size
+ a 32-d) + atracción por centroide via cosine similarity. Implementa
+ el "imán" sin LLM.
+- **Phase D**: módulo `nouser-nous` aparte para el LLM real
+ (Llama/ONNX). En `priority_contexts.test` el Init pinea a
+ `mock-nous` (embeddings determinísticos); en `prod` a `real-nous`.
+- **Polish**: labels de Mónada incluir 2-3 componentes del path para
+ desambiguar `src/` repetidos en monorepo.
+
+Workspace: 0 errores, 0 warnings. Tests acumulados: 58
+(card 11, broker 15, handshake codec+transport 2 + integ 7,
+card-wit 4, admin 0, nouser-card 6, nouser-core 13).
+
### feat(broker): priority contexts — biases per-contexto operativo
- `brahman-card::ContextBias { pin_to: Option<String>, priority_offset: i8 }`
declara un override per-contexto.
diff --git a/Cargo.lock b/Cargo.lock
index 066f436..34ded5b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6045,6 +6045,29 @@ dependencies = [
"windows-sys 0.48.0",
]
+[[package]]
+name = "nouser-card"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "ulid",
+]
+
+[[package]]
+name = "nouser-core"
+version = "0.1.0"
+dependencies = [
+ "nouser-card",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror 2.0.18",
+ "ulid",
+ "walkdir",
+]
+
[[package]]
name = "ntapi"
version = "0.4.3"
diff --git a/Cargo.toml b/Cargo.toml
index a894516..3246b48 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -64,6 +64,12 @@ members = [
# ============================================================
"crates/modules/nakui/core",
+ # ============================================================
+ # modules/nouser/ — explorador de Mónadas (nuevo)
+ # ============================================================
+ "crates/modules/nouser/card",
+ "crates/modules/nouser/core",
+
# ============================================================
# apps/ — apps que consumen el protocolo (yahweh modules+shell)
# ============================================================
diff --git a/crates/modules/nouser/card/Cargo.toml b/crates/modules/nouser/card/Cargo.toml
new file mode 100644
index 0000000..4409b30
--- /dev/null
+++ b/crates/modules/nouser/card/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "nouser-card"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Nouser — manifiesto de Mónada (agrupación semántica de archivos). Espejo de brahman-card pero para datos."
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
diff --git a/crates/modules/nouser/card/src/lib.rs b/crates/modules/nouser/card/src/lib.rs
new file mode 100644
index 0000000..1a55a6d
--- /dev/null
+++ b/crates/modules/nouser/card/src/lib.rs
@@ -0,0 +1,334 @@
+//! `nouser-card` — manifiesto de Mónada.
+//!
+//! Una **Mónada** es una agrupación semántica de archivos: el archivo
+//! físico no se mueve, pero su pertenencia se modela por un objeto
+//! ([`MonadManifest`]) con identidad propia, métricas y un "lente" de
+//! visualización. La idea hereda el espíritu de la Tarjeta de
+//! Presentación de Brahman (`brahman-card::Card`): un manifiesto
+//! tipado, validado y serializable que define qué es la entidad y
+//! cómo el sistema debe interactuar con ella.
+//!
+//! Diferencia con `brahman-card::Card`:
+//!
+//! | brahman::Card | nouser::MonadManifest |
+//! |-------------------------------------|-------------------------------|
+//! | Describe una **entidad runtime** | Describe una **agrupación** |
+//! | Tiene `payload`/`soma`/`supervision`| No tiene proceso detrás |
+//! | Vive durante una sesión | Vive en una DB persistente |
+//! | Fluye por handshake/postcard | Fluye por queries del backend |
+//!
+//! Este crate sólo define los tipos. La lógica de scan, cluster,
+//! attraction vive en `nouser-core`.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+use ulid::Ulid;
+
+// Re-export para consumidores
+pub use ::ulid;
+
+/// Versión del esquema del manifiesto. Bump al cambiar el schema.
+pub const MONAD_SCHEMA_VERSION: u16 = 1;
+
+/// Identificador opaco de un archivo registrado en la DB.
+pub type FileId = Ulid;
+
+/// Identificador opaco de una Mónada.
+pub type MonadId = Ulid;
+
+// =====================================================================
+// FileEntry — el archivo como dato indexado
+// =====================================================================
+
+/// Registro físico de un archivo en la DB. Es la unidad atómica que
+/// pertenece a (potencialmente varias) Mónadas.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FileEntry {
+ pub id: FileId,
+ pub path: PathBuf,
+ /// Hash de contenido (blake3) — sólo se computa si el archivo es
+ /// chico o el usuario lo pidió. `None` por default en Phase 0.
+ #[serde(default)]
+ pub content_hash: Option<[u8; 32]>,
+ /// Tamaño en bytes.
+ pub size: u64,
+ /// `mtime` como ms desde UNIX_EPOCH.
+ pub mtime_ms: u64,
+ /// Extensión normalizada en lowercase, sin punto. `None` si no tiene.
+ #[serde(default)]
+ pub extension: Option<String>,
+}
+
+// =====================================================================
+// Lens — la "vista" preferida de una Mónada
+// =====================================================================
+
+/// Lente de visualización dominante. La UI (yahweh) elige cómo renderizar
+/// los miembros de una Mónada según este hint.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Lens {
+ /// Grid genérico: thumbnail + nombre + meta.
+ #[default]
+ Grid,
+ /// Editor de código con highlighting (rs, py, ts, ...).
+ Code,
+ /// Galería de imágenes (png, jpg, svg, ...).
+ Gallery,
+ /// Vista tabular (csv, sqlite, ...).
+ Database,
+ /// Texto renderizado (md, rst, txt).
+ Markdown,
+ /// Árbol jerárquico (cuando la Mónada es estructural).
+ Tree,
+}
+
+// =====================================================================
+// MonadManifest — la Tarjeta de Presentación de la Mónada
+// =====================================================================
+
+/// Manifiesto de una Mónada. Equivalente conceptual a la Tarjeta de
+/// Presentación de Brahman, pero para una agrupación de datos.
+///
+/// Se serializa a JSON/TOML para persistencia y debugging; es el
+/// "ADN" que la UI lee para saber cómo presentar la Mónada sin tocar
+/// el disco.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct MonadManifest {
+ /// Versión del esquema. Bump = romper compatibilidad de DB.
+ pub schema_version: u16,
+
+ /// Identificador opaco. ULID — orderable por tiempo de creación.
+ pub id: MonadId,
+
+ /// Mónada de la que ésta fue derivada (split, merge), si aplica.
+ #[serde(default)]
+ pub lineage: Option<MonadId>,
+
+ /// Nombre humano corto (1-4 palabras, generado por reglas o por Nous).
+ pub label: String,
+
+ /// Resumen de propósito (1-2 oraciones). Generado por Nous cuando
+ /// la masa de la Mónada justifica la consulta.
+ #[serde(default)]
+ pub summary: String,
+
+ /// Centroide vectorial (embedding promedio de los miembros). Vacío
+ /// en Phase 0 (sin embeddings); se llena cuando entran las
+ /// pseudo-embeddings o el modelo real.
+ #[serde(default)]
+ pub centroid: Vec<f32>,
+
+ /// Tokens dominantes: extensiones, palabras clave, etc.
+ /// 5-10 elementos típicamente.
+ #[serde(default)]
+ pub keywords: Vec<String>,
+
+ /// Cantidad de miembros (== `members.len()`). Cacheado para evitar
+ /// el cost de leer la lista cada vez.
+ pub cardinality: u32,
+
+ /// Métrica de dispersión interna [0.0, 1.0]:
+ /// - 0.0: todos los miembros son muy similares (Mónada coherente).
+ /// - 1.0: miembros muy heterogéneos (sugerencia: bifurcar).
+ ///
+ /// Calculada como entropía de Shannon normalizada sobre las
+ /// extensiones de los miembros.
+ #[serde(default)]
+ pub entropy: f32,
+
+ /// Lente preferido para visualización en la UI.
+ #[serde(default)]
+ pub dominant_lens: Lens,
+
+ /// Archivos anclados manualmente: NO se mueven en re-clustering
+ /// automático. El usuario "fija" estos miembros.
+ #[serde(default)]
+ pub pins: BTreeSet<FileId>,
+
+ /// IDs de archivos miembros (incluye pins).
+ pub members: BTreeSet<FileId>,
+
+ /// Unix ms de creación de la Mónada.
+ pub created_at_ms: u64,
+
+ /// Unix ms de la última actualización (re-cluster, re-name, ...).
+ pub updated_at_ms: u64,
+
+ /// Forward-compat: campos JSON desconocidos preservados.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub extensions: BTreeMap<String, serde_json::Value>,
+}
+
+// =====================================================================
+// Errores y validación
+// =====================================================================
+
+#[derive(Debug, Error)]
+pub enum MonadError {
+ #[error("schema mismatch: got {got}, expected {expected}")]
+ SchemaMismatch { got: u16, expected: u16 },
+ #[error("label vacío")]
+ EmptyLabel,
+ #[error("label demasiado largo: {0} bytes (max 256)")]
+ LabelTooLong(usize),
+ #[error("entropía fuera de [0,1]: {0}")]
+ InvalidEntropy(f32),
+ #[error("Monad sin miembros y sin pins")]
+ Empty,
+ #[error("cardinalidad declarada {declared} ≠ members.len() {actual}")]
+ CardinalityMismatch { declared: u32, actual: u32 },
+ #[error("JSON inválido: {0}")]
+ Json(#[from] serde_json::Error),
+}
+
+impl MonadManifest {
+ /// Constructor con defaults razonables. `id` y timestamps se
+ /// generan; resto vacío.
+ pub fn new(label: impl Into<String>) -> Self {
+ let now_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0);
+ Self {
+ schema_version: MONAD_SCHEMA_VERSION,
+ id: Ulid::new(),
+ lineage: None,
+ label: label.into(),
+ summary: String::new(),
+ centroid: Vec::new(),
+ keywords: Vec::new(),
+ cardinality: 0,
+ entropy: 0.0,
+ dominant_lens: Lens::default(),
+ pins: BTreeSet::new(),
+ members: BTreeSet::new(),
+ created_at_ms: now_ms,
+ updated_at_ms: now_ms,
+ extensions: BTreeMap::new(),
+ }
+ }
+
+ /// Validación semántica.
+ pub fn validate(&self) -> Result<(), MonadError> {
+ if self.schema_version != MONAD_SCHEMA_VERSION {
+ return Err(MonadError::SchemaMismatch {
+ got: self.schema_version,
+ expected: MONAD_SCHEMA_VERSION,
+ });
+ }
+ if self.label.trim().is_empty() {
+ return Err(MonadError::EmptyLabel);
+ }
+ if self.label.len() > 256 {
+ return Err(MonadError::LabelTooLong(self.label.len()));
+ }
+ if !(0.0..=1.0).contains(&self.entropy) {
+ return Err(MonadError::InvalidEntropy(self.entropy));
+ }
+ if self.members.is_empty() && self.pins.is_empty() {
+ return Err(MonadError::Empty);
+ }
+ let actual = self.members.len() as u32;
+ if self.cardinality != actual {
+ return Err(MonadError::CardinalityMismatch {
+ declared: self.cardinality,
+ actual,
+ });
+ }
+ Ok(())
+ }
+
+ /// Serializa a JSON pretty.
+ pub fn to_json_pretty(&self) -> Result<String, MonadError> {
+ Ok(serde_json::to_string_pretty(self)?)
+ }
+
+ /// Deserializa desde JSON y valida.
+ pub fn from_json(src: &str) -> Result<Self, MonadError> {
+ let m: Self = serde_json::from_str(src)?;
+ m.validate()?;
+ Ok(m)
+ }
+
+ /// Recalcula `cardinality` y `updated_at_ms` desde `members`.
+ /// Usar tras mutaciones del set de miembros.
+ pub fn touch(&mut self) {
+ self.cardinality = self.members.len() as u32;
+ self.updated_at_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_millis() as u64)
+ .unwrap_or(0);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn validates_minimal() {
+ let mut m = MonadManifest::new("test");
+ m.members.insert(Ulid::new());
+ m.touch();
+ m.validate().expect("debe validar");
+ }
+
+ #[test]
+ fn empty_label_rejected() {
+ let mut m = MonadManifest::new("x");
+ m.label = String::new();
+ m.members.insert(Ulid::new());
+ m.touch();
+ assert!(matches!(m.validate(), Err(MonadError::EmptyLabel)));
+ }
+
+ #[test]
+ fn entropy_out_of_range_rejected() {
+ let mut m = MonadManifest::new("x");
+ m.members.insert(Ulid::new());
+ m.entropy = 1.5;
+ m.touch();
+ assert!(matches!(m.validate(), Err(MonadError::InvalidEntropy(_))));
+ }
+
+ #[test]
+ fn empty_members_rejected() {
+ let m = MonadManifest::new("x");
+ assert!(matches!(m.validate(), Err(MonadError::Empty)));
+ }
+
+ #[test]
+ fn cardinality_mismatch_caught() {
+ let mut m = MonadManifest::new("x");
+ m.members.insert(Ulid::new());
+ // No llamamos touch — cardinality queda en 0 con 1 miembro.
+ assert!(matches!(
+ m.validate(),
+ Err(MonadError::CardinalityMismatch { .. })
+ ));
+ }
+
+ #[test]
+ fn json_roundtrip() {
+ let mut m = MonadManifest::new("test-monad");
+ m.members.insert(Ulid::new());
+ m.members.insert(Ulid::new());
+ m.keywords = vec!["rs".into(), "toml".into()];
+ m.summary = "test summary".into();
+ m.dominant_lens = Lens::Code;
+ m.touch();
+ let s = m.to_json_pretty().unwrap();
+ let m2 = MonadManifest::from_json(&s).unwrap();
+ assert_eq!(m2.label, m.label);
+ assert_eq!(m2.cardinality, 2);
+ assert_eq!(m2.dominant_lens, Lens::Code);
+ assert_eq!(m2.keywords, m.keywords);
+ }
+}
diff --git a/crates/modules/nouser/core/Cargo.toml b/crates/modules/nouser/core/Cargo.toml
new file mode 100644
index 0000000..023d764
--- /dev/null
+++ b/crates/modules/nouser/core/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "nouser-core"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Nouser — explorador de Mónadas: scanner, clustering determinista, DB en memoria."
+
+[dependencies]
+nouser-card = { path = "../card" }
+serde = { workspace = true }
+serde_json = { workspace = true }
+thiserror = { workspace = true }
+ulid = { workspace = true }
+walkdir = "2"
+
+[dev-dependencies]
+tempfile = { workspace = true }
+
+[[bin]]
+name = "nouser"
+path = "src/bin/nouser.rs"
diff --git a/crates/modules/nouser/core/src/bin/nouser.rs b/crates/modules/nouser/core/src/bin/nouser.rs
new file mode 100644
index 0000000..03fa85d
--- /dev/null
+++ b/crates/modules/nouser/core/src/bin/nouser.rs
@@ -0,0 +1,157 @@
+//! `nouser` 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 nouser_core::{
+ cluster, db,
+ scanner::{self, ScanConfig},
+};
+
+fn main() -> ExitCode {
+ let args: Vec<String> = std::env::args().collect();
+ let prog = args.first().cloned().unwrap_or_else(|| "nouser".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),
+ "--help" | "-h" | "help" => {
+ print_usage(&prog);
+ return ExitCode::SUCCESS;
+ }
+ other => {
+ eprintln!("nouser: comando desconocido '{other}'");
+ print_usage(&prog);
+ return ExitCode::from(2);
+ }
+ };
+
+ match result {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(e) => {
+ eprintln!("nouser: {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!();
+ eprintln!("env:");
+ eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
+}
+
+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 = db::MonadDb::new();
+ db.ingest_files(files);
+ db.replace_monads(monads);
+ Ok((db, n_files))
+}
+
+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(())
+}
diff --git a/crates/modules/nouser/core/src/cluster.rs b/crates/modules/nouser/core/src/cluster.rs
new file mode 100644
index 0000000..b8deac0
--- /dev/null
+++ b/crates/modules/nouser/core/src/cluster.rs
@@ -0,0 +1,240 @@
+//! 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 nouser_card::{FileEntry, Lens, MonadManifest};
+
+/// 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> {
+ 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;
+ }
+ out.push(build_monad(&parent, &group));
+ }
+ out
+}
+
+fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest {
+ let label = parent
+ .file_name()
+ .and_then(|s| s.to_str())
+ .unwrap_or("unnamed")
+ .to_string();
+
+ let keywords = top_extensions(group, 5);
+ let lens = pick_lens(group);
+ let entropy = shannon_entropy_normalized(group);
+
+ let summary = build_summary(parent, group, &keywords);
+
+ let mut m = MonadManifest::new(label);
+ m.summary = summary;
+ m.keywords = keywords;
+ m.dominant_lens = lens;
+ m.entropy = entropy;
+ m.members = group.iter().map(|f| f.id).collect();
+ m.touch();
+ m
+}
+
+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.
+fn pick_lens(files: &[&FileEntry]) -> Lens {
+ let dominant = top_extensions(files, 1).into_iter().next();
+ 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,
+ }
+}
+
+/// 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 nouser_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();
+ assert!(labels.iter().any(|l| l.as_str() == "src"));
+ assert!(labels.iter().any(|l| l.as_str() == "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, "sub");
+ }
+
+ #[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"]);
+ }
+}
diff --git a/crates/modules/nouser/core/src/db.rs b/crates/modules/nouser/core/src/db.rs
new file mode 100644
index 0000000..1d76023
--- /dev/null
+++ b/crates/modules/nouser/core/src/db.rs
@@ -0,0 +1,149 @@
+//! DB en memoria de Mónadas y archivos.
+//!
+//! Phase A: store volátil con índices `BTreeMap<Id, Manifest>` para
+//! ambos lados. Phase B traerá persistencia (sled o sqlite) y un
+//! índice `file_id → Vec<monad_id>` (membership).
+
+use std::collections::BTreeMap;
+
+use nouser_card::{FileEntry, FileId, MonadId, MonadManifest};
+
+/// Store de Mónadas + archivos. Operaciones lock-free (mut & por
+/// usuario externo). Para uso multi-thread, envolvé en `Mutex/RwLock`.
+#[derive(Debug, Default)]
+pub struct MonadDb {
+ files: BTreeMap<FileId, FileEntry>,
+ monads: BTreeMap<MonadId, MonadManifest>,
+}
+
+impl MonadDb {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ // ---- Files ----
+
+ pub fn insert_file(&mut self, file: FileEntry) -> Option<FileEntry> {
+ self.files.insert(file.id, file)
+ }
+
+ pub fn ingest_files(&mut self, files: Vec<FileEntry>) {
+ for f in files {
+ self.files.insert(f.id, 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> {
+ self.monads.insert(monad.id, monad)
+ }
+
+ pub fn replace_monads(&mut self, monads: Vec<MonadManifest>) {
+ self.monads.clear();
+ for m in monads {
+ self.monads.insert(m.id, 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(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use nouser_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());
+ }
+
+ #[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);
+ // Sólo el archivo realmente presente en files.
+ 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");
+ }
+}
diff --git a/crates/modules/nouser/core/src/lib.rs b/crates/modules/nouser/core/src/lib.rs
new file mode 100644
index 0000000..b713690
--- /dev/null
+++ b/crates/modules/nouser/core/src/lib.rs
@@ -0,0 +1,32 @@
+//! `nouser-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 scanner;
+
+pub use nouser_card::*;
diff --git a/crates/modules/nouser/core/src/scanner.rs b/crates/modules/nouser/core/src/scanner.rs
new file mode 100644
index 0000000..ed4581e
--- /dev/null
+++ b/crates/modules/nouser/core/src/scanner.rs
@@ -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 nouser_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()));
+ }
+}