Files
brahman/crates/modules/nouser/core/src/scanner.rs
T
Sergio 7bdc26e61a 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>
2026-05-08 18:03:49 +00:00

179 lines
5.1 KiB
Rust

//! 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()));
}
}