⎇ d7b4886164df5ef048a7c8c0d735fb83555c7990
crates/modules/nouser/core/src/scanner.rs
Historia
Quién tocó qué
crudo
feat(nouser): Phase A — mecanismo determinista de Mónadas
1//! Recorrido de directorios. Sólo metadatos — no lee contenido.
2//!
3//! Usa `walkdir` (sequential). Para árboles muy grandes considerar
4//! migrar a `jwalk` (paralelo); por ahora la simplicidad gana.
5
6use std::path::{Path, PathBuf};
7use std::time::UNIX_EPOCH;
8
9use nouser_card::{FileEntry, FileId};
10use thiserror::Error;
11use ulid::Ulid;
12use walkdir::WalkDir;
13
14#[derive(Debug, Error)]
15pub enum ScanError {
16 #[error("ruta no existe: {0}")]
17 NotFound(PathBuf),
18 #[error("no se pudo leer: {0}")]
19 Walk(String),
20}
21
22/// Configuración del scan.
23#[derive(Debug, Clone)]
24pub struct ScanConfig {
25 /// Profundidad máxima (None = ilimitada).
26 pub max_depth: Option<usize>,
27 /// Sigue symlinks (default: false, evita ciclos).
28 pub follow_links: bool,
29 /// Ignora archivos ocultos (.dotfiles).
30 pub skip_hidden: bool,
31}
32
33impl Default for ScanConfig {
34 fn default() -> Self {
35 Self {
36 max_depth: None,
37 follow_links: false,
38 skip_hidden: true,
39 }
40 }
41}
42
43/// Recorre `root` y devuelve un `FileEntry` por cada archivo regular.
44/// Errores de permisos en sub-paths se ignoran silenciosamente.
45pub fn scan_directory(root: &Path, config: &ScanConfig) -> Result<Vec<FileEntry>, ScanError> {
46 if !root.exists() {
47 return Err(ScanError::NotFound(root.to_path_buf()));
48 }
49
50 let mut walker = WalkDir::new(root).follow_links(config.follow_links);
51 if let Some(d) = config.max_depth {
52 walker = walker.max_depth(d);
53 }
54
55 let mut entries = Vec::new();
56 for entry_result in walker {
57 let entry = match entry_result {
58 Ok(e) => e,
59 Err(_) => continue,
60 };
61 if !entry.file_type().is_file() {
62 continue;
63 }
64 if config.skip_hidden && is_hidden(entry.path()) {
65 continue;
66 }
67 let metadata = match entry.metadata() {
68 Ok(m) => m,
69 Err(_) => continue,
70 };
71 let mtime_ms = metadata
72 .modified()
73 .ok()
74 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
75 .map(|d| d.as_millis() as u64)
76 .unwrap_or(0);
77 let extension = entry
78 .path()
79 .extension()
80 .and_then(|s| s.to_str())
81 .map(|s| s.to_lowercase());
82
83 entries.push(FileEntry {
84 id: FileId::from(Ulid::new()),
85 path: entry.path().to_path_buf(),
86 content_hash: None,
87 size: metadata.len(),
88 mtime_ms,
89 extension,
90 });
91 }
92 Ok(entries)
93}
94
95/// `true` si alguno de los componentes del path empieza con `.`.
96/// Excluye el primer componente (root) para no descartar el directorio raíz
97/// si el usuario apuntó a un dotfile-dir explícito.
98fn is_hidden(path: &Path) -> bool {
99 path.file_name()
100 .and_then(|n| n.to_str())
101 .map(|n| n.starts_with('.'))
102 .unwrap_or(false)
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use std::fs;
109
110 fn write(path: &Path, content: &str) {
111 if let Some(parent) = path.parent() {
112 fs::create_dir_all(parent).unwrap();
113 }
114 fs::write(path, content).unwrap();
115 }
116
117 #[test]
118 fn scans_basic_tree() {
119 let tmp = tempfile::tempdir().unwrap();
120 let root = tmp.path();
121 write(&root.join("a.rs"), "fn main(){}");
122 write(&root.join("b.rs"), "fn b(){}");
123 write(&root.join("data/x.json"), "{}");
124 write(&root.join("data/y.json"), "{}");
125
126 let files = scan_directory(root, &ScanConfig::default()).unwrap();
127 assert_eq!(files.len(), 4);
128 let exts: std::collections::BTreeSet<_> = files
129 .iter()
130 .filter_map(|f| f.extension.clone())
131 .collect();
132 assert!(exts.contains("rs"));
133 assert!(exts.contains("json"));
134 }
135
136 #[test]
137 fn skips_hidden_by_default() {
138 let tmp = tempfile::tempdir().unwrap();
139 let root = tmp.path();
140 write(&root.join("visible.txt"), "x");
141 write(&root.join(".hidden"), "x");
142
143 let files = scan_directory(root, &ScanConfig::default()).unwrap();
144 assert_eq!(files.len(), 1);
145 assert!(files[0].path.ends_with("visible.txt"));
146 }
147
148 #[test]
149 fn missing_root_errors() {
150 let p = std::path::Path::new("/nonexistent-12345-abc");
151 assert!(matches!(
152 scan_directory(p, &ScanConfig::default()),
153 Err(ScanError::NotFound(_))
154 ));
155 }
156
157 #[test]
158 fn max_depth_limits() {
159 let tmp = tempfile::tempdir().unwrap();
160 let root = tmp.path();
161 write(&root.join("top.txt"), "x");
162 write(&root.join("a/b/deep.txt"), "x");
163
164 let cfg = ScanConfig {
165 max_depth: Some(1),
166 ..Default::default()
167 };
168 let files = scan_directory(root, &cfg).unwrap();
169 // max_depth=1 incluye archivos en root pero no anidados profundos.
170 let names: Vec<_> = files
171 .iter()
172 .filter_map(|f| f.path.file_name().and_then(|s| s.to_str()))
173 .map(String::from)
174 .collect();
175 assert!(names.contains(&"top.txt".to_string()));
176 assert!(!names.contains(&"deep.txt".to_string()));
177 }
178}