initial project scaffold
Rust workspace with nomina-core (rename engine) and nomina-app (Tauri v2 shell). React/TypeScript frontend with tabbed rule panels, virtual-scrolled file list, and Zustand state management. All 9 rule types implemented with 25 passing tests.
This commit is contained in:
115
crates/nomina-core/src/scanner.rs
Normal file
115
crates/nomina-core/src/scanner.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::filter::FilterConfig;
|
||||
use crate::FileEntry;
|
||||
|
||||
pub struct FileScanner {
|
||||
pub root: PathBuf,
|
||||
pub filters: FilterConfig,
|
||||
}
|
||||
|
||||
impl FileScanner {
|
||||
pub fn new(root: PathBuf, filters: FilterConfig) -> Self {
|
||||
Self { root, filters }
|
||||
}
|
||||
|
||||
pub fn scan(&self) -> Vec<FileEntry> {
|
||||
let max_depth = self.filters.subfolder_depth.map(|d| d + 1).unwrap_or(usize::MAX);
|
||||
|
||||
let walker = WalkDir::new(&self.root)
|
||||
.max_depth(max_depth)
|
||||
.follow_links(false);
|
||||
|
||||
let mut entries: Vec<FileEntry> = Vec::new();
|
||||
|
||||
for result in walker {
|
||||
let dir_entry = match result {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// skip the root directory itself
|
||||
if dir_entry.path() == self.root {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match dir_entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let path = dir_entry.path().to_path_buf();
|
||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
let is_dir = metadata.is_dir();
|
||||
let is_hidden = is_hidden_file(&path);
|
||||
|
||||
let (stem, extension) = if is_dir {
|
||||
(name.clone(), String::new())
|
||||
} else {
|
||||
split_filename(&name)
|
||||
};
|
||||
|
||||
let created = file_created(&metadata);
|
||||
let modified = file_modified(&metadata);
|
||||
let accessed = file_accessed(&metadata);
|
||||
|
||||
let entry = FileEntry {
|
||||
path,
|
||||
name,
|
||||
stem,
|
||||
extension,
|
||||
size: metadata.len(),
|
||||
is_dir,
|
||||
is_hidden,
|
||||
created,
|
||||
modified,
|
||||
accessed,
|
||||
};
|
||||
|
||||
if self.filters.matches(&entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// natural sort
|
||||
entries.sort_by(|a, b| natord::compare(&a.name, &b.name));
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
fn split_filename(name: &str) -> (String, String) {
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() => (stem.to_string(), ext.to_string()),
|
||||
_ => (name.to_string(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hidden_file(path: &Path) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
|
||||
return meta.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0;
|
||||
}
|
||||
}
|
||||
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn file_created(meta: &std::fs::Metadata) -> Option<DateTime<Utc>> {
|
||||
meta.created().ok().map(|t| DateTime::<Utc>::from(t))
|
||||
}
|
||||
|
||||
fn file_modified(meta: &std::fs::Metadata) -> Option<DateTime<Utc>> {
|
||||
meta.modified().ok().map(|t| DateTime::<Utc>::from(t))
|
||||
}
|
||||
|
||||
fn file_accessed(meta: &std::fs::Metadata) -> Option<DateTime<Utc>> {
|
||||
meta.accessed().ok().map(|t| DateTime::<Utc>::from(t))
|
||||
}
|
||||
Reference in New Issue
Block a user