tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/nouser/core/src/scanner.rs Rust · 178 lines · 5.1 KB History Who touched what raw

feat(nouser): Phase A — mecanismo determinista de Mónadas 7bdc26e61 · Sergio ·

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}