use dashmap::DashMap;
use scribe_core::{bytes_to_human, Result, ScribeError};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMetadata {
pub path: PathBuf,
pub size: u64,
pub size_human: String,
pub created: Option<u64>,
pub modified: Option<u64>,
pub accessed: Option<u64>,
pub readonly: bool,
pub hidden: bool,
pub executable: bool,
pub symlink: bool,
pub symlink_target: Option<PathBuf>,
pub permissions: u32,
pub file_type: FileSystemType,
pub inode: Option<u64>,
pub links: Option<u64>,
pub uid: Option<u32>,
pub gid: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileSystemType {
RegularFile,
SymbolicLink,
Directory,
FIFO,
Socket,
CharacterDevice,
BlockDevice,
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SizeStats {
pub total_size: u64,
pub total_size_human: String,
pub file_count: usize,
pub average_size: u64,
pub median_size: u64,
pub min_size: u64,
pub max_size: u64,
pub size_distribution: SizeDistribution,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SizeDistribution {
pub tiny: usize, pub small: usize, pub medium: usize, pub large: usize, pub huge: usize, }
pub struct MetadataExtractor {
cache: DashMap<PathBuf, FileMetadata>,
cache_enabled: bool,
}
impl Default for FileMetadata {
fn default() -> Self {
Self {
path: PathBuf::new(),
size: 0,
size_human: "0 B".to_string(),
created: None,
modified: None,
accessed: None,
readonly: false,
hidden: false,
executable: false,
symlink: false,
symlink_target: None,
permissions: 0,
file_type: FileSystemType::Unknown,
inode: None,
links: None,
uid: None,
gid: None,
}
}
}
impl MetadataExtractor {
pub fn new() -> Self {
Self {
cache: DashMap::new(),
cache_enabled: true,
}
}
pub fn without_cache() -> Self {
Self {
cache: DashMap::new(),
cache_enabled: false,
}
}
pub async fn extract_metadata(&self, path: &Path) -> Result<FileMetadata> {
if self.cache_enabled {
if let Some(cached) = self.cache.get(path) {
return Ok(cached.clone());
}
}
let metadata = self.extract_metadata_uncached(path).await?;
if self.cache_enabled {
self.cache.insert(path.to_path_buf(), metadata.clone());
}
Ok(metadata)
}
async fn extract_metadata_uncached(&self, path: &Path) -> Result<FileMetadata> {
let std_metadata = tokio::fs::symlink_metadata(path).await.map_err(|e| {
ScribeError::io(
format!("Failed to read metadata for {}: {}", path.display(), e),
e,
)
})?;
let size = std_metadata.len();
let size_human = bytes_to_human(size);
let created = system_time_to_timestamp(std_metadata.created().ok());
let modified = system_time_to_timestamp(std_metadata.modified().ok());
let accessed = system_time_to_timestamp(std_metadata.accessed().ok());
let file_type = classify_file_type(&std_metadata);
let (symlink, symlink_target) = if std_metadata.file_type().is_symlink() {
let target = tokio::fs::read_link(path).await.ok();
(true, target)
} else {
(false, None)
};
let (permissions, readonly, hidden, executable, inode, links, uid, gid) =
extract_platform_metadata(path, &std_metadata)?;
Ok(FileMetadata {
path: path.to_path_buf(),
size,
size_human,
created,
modified,
accessed,
readonly,
hidden,
executable,
symlink,
symlink_target,
permissions,
file_type,
inode,
links,
uid,
gid,
})
}
pub async fn extract_metadata_batch(&self, paths: &[PathBuf]) -> Vec<Result<FileMetadata>> {
let mut results = Vec::with_capacity(paths.len());
for path in paths {
results.push(self.extract_metadata(path).await);
}
results
}
pub fn calculate_size_stats(&self, files: &[FileMetadata]) -> SizeStats {
if files.is_empty() {
return SizeStats::default();
}
let mut sizes: Vec<u64> = files.iter().map(|f| f.size).collect();
sizes.sort_unstable();
let total_size = sizes.iter().sum();
let file_count = files.len();
let average_size = total_size / file_count as u64;
let median_size = if file_count % 2 == 0 {
(sizes[file_count / 2 - 1] + sizes[file_count / 2]) / 2
} else {
sizes[file_count / 2]
};
let min_size = sizes[0];
let max_size = sizes[sizes.len() - 1];
let mut distribution = SizeDistribution::default();
for &size in &sizes {
match size {
0..=1024 => distribution.tiny += 1,
1025..=10240 => distribution.small += 1,
10241..=102400 => distribution.medium += 1,
102401..=1048576 => distribution.large += 1,
_ => distribution.huge += 1,
}
}
SizeStats {
total_size,
total_size_human: bytes_to_human(total_size),
file_count,
average_size,
median_size,
min_size,
max_size,
size_distribution: distribution,
}
}
pub fn clear_cache(&self) {
self.cache.clear();
}
pub fn cache_stats(&self) -> (usize, usize) {
(self.cache.len(), self.cache.capacity())
}
pub fn is_likely_text_file(&self, metadata: &FileMetadata) -> bool {
if metadata.size > 10 * 1024 * 1024 {
return false;
}
matches!(
metadata.file_type,
FileSystemType::RegularFile | FileSystemType::SymbolicLink
)
}
pub fn is_recently_modified(&self, metadata: &FileMetadata, hours: u64) -> bool {
if let Some(modified) = metadata.modified {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let threshold = hours * 3600;
now.saturating_sub(modified) <= threshold
} else {
false
}
}
}
impl Default for MetadataExtractor {
fn default() -> Self {
Self::new()
}
}
fn system_time_to_timestamp(time: Option<SystemTime>) -> Option<u64> {
time.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
}
fn classify_file_type(metadata: &fs::Metadata) -> FileSystemType {
let file_type = metadata.file_type();
if file_type.is_file() {
FileSystemType::RegularFile
} else if file_type.is_dir() {
FileSystemType::Directory
} else if file_type.is_symlink() {
FileSystemType::SymbolicLink
} else {
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
if file_type.is_fifo() {
return FileSystemType::FIFO;
} else if file_type.is_socket() {
return FileSystemType::Socket;
} else if file_type.is_char_device() {
return FileSystemType::CharacterDevice;
} else if file_type.is_block_device() {
return FileSystemType::BlockDevice;
}
}
FileSystemType::Unknown
}
}
#[cfg(unix)]
fn extract_platform_metadata(
path: &Path,
metadata: &fs::Metadata,
) -> Result<(
u32,
bool,
bool,
bool,
Option<u64>,
Option<u64>,
Option<u32>,
Option<u32>,
)> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let permissions = metadata.permissions().mode();
let readonly = !metadata.permissions().readonly();
let hidden = path
.file_name()
.and_then(|name| name.to_str())
.map_or(false, |name| name.starts_with('.'));
let executable = permissions & 0o111 != 0;
let inode = Some(metadata.ino());
let links = Some(metadata.nlink());
let uid = Some(metadata.uid());
let gid = Some(metadata.gid());
Ok((
permissions,
readonly,
hidden,
executable,
inode,
links,
uid,
gid,
))
}
#[cfg(windows)]
fn extract_platform_metadata(
path: &Path,
metadata: &fs::Metadata,
) -> Result<(
u32,
bool,
bool,
bool,
Option<u64>,
Option<u64>,
Option<u32>,
Option<u32>,
)> {
use std::os::windows::fs::MetadataExt;
let permissions = 0; let readonly = metadata.permissions().readonly();
let hidden = metadata.file_attributes() & 0x2 != 0;
let executable = path
.extension()
.and_then(|ext| ext.to_str())
.map_or(false, |ext| {
matches!(
ext.to_lowercase().as_str(),
"exe" | "bat" | "cmd" | "com" | "scr"
)
});
let inode = None;
let links = None;
let uid = None;
let gid = None;
Ok((
permissions,
readonly,
hidden,
executable,
inode,
links,
uid,
gid,
))
}
impl SizeStats {
pub fn from_sizes(sizes: &[u64]) -> Self {
let mut extractor = MetadataExtractor::new();
let fake_metadata: Vec<FileMetadata> = sizes
.iter()
.enumerate()
.map(|(i, &size)| FileMetadata {
path: PathBuf::from(format!("file_{}", i)),
size,
size_human: bytes_to_human(size),
..Default::default()
})
.collect();
extractor.calculate_size_stats(&fake_metadata)
}
pub fn summary(&self) -> String {
format!(
"Files: {}, Total: {}, Avg: {}, Range: {} - {}",
self.file_count,
self.total_size_human,
bytes_to_human(self.average_size),
bytes_to_human(self.min_size),
bytes_to_human(self.max_size)
)
}
pub fn distribution_summary(&self) -> String {
format!(
"Tiny: {}, Small: {}, Medium: {}, Large: {}, Huge: {}",
self.size_distribution.tiny,
self.size_distribution.small,
self.size_distribution.medium,
self.size_distribution.large,
self.size_distribution.huge
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
use tokio::fs as async_fs;
#[tokio::test]
async fn test_metadata_extraction() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.txt");
let content = "Hello, world! This is a test file.";
fs::write(&test_file, content).unwrap();
let mut extractor = MetadataExtractor::new();
let metadata = extractor.extract_metadata(&test_file).await.unwrap();
assert_eq!(metadata.path, test_file);
assert_eq!(metadata.size, content.len() as u64);
assert!(!metadata.size_human.is_empty());
assert!(metadata.modified.is_some());
assert_eq!(metadata.file_type, FileSystemType::RegularFile);
assert!(!metadata.symlink);
}
#[tokio::test]
async fn test_symlink_detection() {
let temp_dir = TempDir::new().unwrap();
let original_file = temp_dir.path().join("original.txt");
let symlink_file = temp_dir.path().join("link.txt");
fs::write(&original_file, "original content").unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(&original_file, &symlink_file).unwrap();
let mut extractor = MetadataExtractor::new();
let metadata = extractor.extract_metadata(&symlink_file).await.unwrap();
assert!(metadata.symlink);
assert_eq!(metadata.symlink_target, Some(original_file));
}
}
#[tokio::test]
async fn test_batch_metadata_extraction() {
let temp_dir = TempDir::new().unwrap();
let mut file_paths = Vec::new();
for i in 0..5 {
let file_path = temp_dir.path().join(format!("test_{}.txt", i));
fs::write(&file_path, format!("Content for file {}", i)).unwrap();
file_paths.push(file_path);
}
let mut extractor = MetadataExtractor::new();
let results = extractor.extract_metadata_batch(&file_paths).await;
assert_eq!(results.len(), 5);
for result in results {
assert!(result.is_ok());
let metadata = result.unwrap();
assert_eq!(metadata.file_type, FileSystemType::RegularFile);
assert!(metadata.size > 0);
}
}
#[tokio::test]
async fn test_size_statistics() {
let temp_dir = TempDir::new().unwrap();
let mut files = Vec::new();
let sizes = [100, 500, 1500, 5000, 50000];
for (i, &size) in sizes.iter().enumerate() {
let file_path = temp_dir.path().join(format!("test_{}.txt", i));
let content = "x".repeat(size);
fs::write(&file_path, content).unwrap();
let mut extractor = MetadataExtractor::new();
let metadata = extractor.extract_metadata(&file_path).await.unwrap();
files.push(metadata);
}
let extractor = MetadataExtractor::new();
let stats = extractor.calculate_size_stats(&files);
assert_eq!(stats.file_count, 5);
assert_eq!(stats.total_size, sizes.iter().sum::<usize>() as u64);
assert_eq!(stats.min_size, 100);
assert_eq!(stats.max_size, 50000);
assert_eq!(stats.size_distribution.tiny, 2); assert_eq!(stats.size_distribution.small, 2); assert_eq!(stats.size_distribution.medium, 1); assert_eq!(stats.size_distribution.large, 0); assert_eq!(stats.size_distribution.huge, 0);
}
#[test]
fn test_size_stats_from_sizes() {
let sizes = [1000, 2000, 3000, 4000, 5000];
let stats = SizeStats::from_sizes(&sizes);
assert_eq!(stats.file_count, 5);
assert_eq!(stats.total_size, 15000);
assert_eq!(stats.average_size, 3000);
assert_eq!(stats.median_size, 3000);
assert_eq!(stats.min_size, 1000);
assert_eq!(stats.max_size, 5000);
}
#[test]
fn test_size_distribution() {
let sizes = [
500, 5000, 50000, 500000, 5000000, ];
let stats = SizeStats::from_sizes(&sizes);
assert_eq!(stats.size_distribution.tiny, 1);
assert_eq!(stats.size_distribution.small, 1);
assert_eq!(stats.size_distribution.medium, 1);
assert_eq!(stats.size_distribution.large, 1);
assert_eq!(stats.size_distribution.huge, 1);
}
#[tokio::test]
async fn test_cache_functionality() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "test content").unwrap();
let mut extractor = MetadataExtractor::new();
let metadata1 = extractor.extract_metadata(&test_file).await.unwrap();
let (cache_size, _) = extractor.cache_stats();
assert_eq!(cache_size, 1);
let metadata2 = extractor.extract_metadata(&test_file).await.unwrap();
assert_eq!(metadata1.size, metadata2.size);
assert_eq!(metadata1.modified, metadata2.modified);
extractor.clear_cache();
let (cache_size, _) = extractor.cache_stats();
assert_eq!(cache_size, 0);
}
#[tokio::test]
async fn test_recently_modified() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "test content").unwrap();
let mut extractor = MetadataExtractor::new();
let metadata = extractor.extract_metadata(&test_file).await.unwrap();
assert!(extractor.is_recently_modified(&metadata, 1));
assert!(extractor.is_recently_modified(&metadata, 24));
}
#[test]
fn test_file_type_classification() {
let sizes = [1000];
let stats = SizeStats::from_sizes(&sizes);
assert_eq!(stats.file_count, 1);
assert_eq!(stats.total_size, 1000);
}
}