use crate::ast_parser::AstParser;
use crate::error::{Result, TailwindError};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ScanConfig {
pub extensions: Vec<String>,
pub include_dirs: Vec<PathBuf>,
pub exclude_dirs: Vec<PathBuf>,
pub exclude_patterns: Vec<String>,
pub max_file_size: Option<usize>,
pub follow_symlinks: bool,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
extensions: vec!["rs".to_string()],
include_dirs: vec![],
exclude_dirs: vec!["target".to_string().into(), ".git".to_string().into()],
exclude_patterns: vec!["*_test.rs".to_string(), "*_tests.rs".to_string()],
max_file_size: Some(10 * 1024 * 1024), follow_symlinks: false,
}
}
}
#[derive(Debug, Clone)]
pub struct ScanResults {
pub classes: HashSet<String>,
pub responsive_classes: HashMap<String, HashSet<String>>,
pub conditional_classes: HashMap<String, HashSet<String>>,
pub classes_by_file: HashMap<PathBuf, HashSet<String>>,
pub stats: ScanStats,
}
#[derive(Debug, Clone)]
pub struct ScanStats {
pub files_scanned: usize,
pub files_skipped: usize,
pub total_classes: usize,
pub unique_classes: usize,
pub duration_ms: u64,
pub total_file_size: u64,
}
#[derive(Debug, Clone)]
pub struct ClassScanner {
config: ScanConfig,
parser: AstParser,
}
impl ClassScanner {
pub fn new() -> Self {
Self {
config: ScanConfig::default(),
parser: AstParser::new(),
}
}
pub fn with_config(config: ScanConfig) -> Self {
Self {
config,
parser: AstParser::new(),
}
}
pub fn scan_directory(&mut self, path: &Path) -> Result<ScanResults> {
let start_time = std::time::Instant::now();
let mut stats = ScanStats {
files_scanned: 0,
files_skipped: 0,
total_classes: 0,
unique_classes: 0,
duration_ms: 0,
total_file_size: 0,
};
let mut classes_by_file = HashMap::new();
let files = self.find_files_to_scan(path)?;
for file_path in files {
if let Some(max_size) = self.config.max_file_size {
if let Ok(metadata) = fs::metadata(&file_path) {
if metadata.len() > max_size as u64 {
stats.files_skipped += 1;
continue;
}
stats.total_file_size += metadata.len();
}
}
match self.parser.parse_file(&file_path) {
Ok(()) => {
stats.files_scanned += 1;
let file_classes: HashSet<String> = self.parser.get_classes().clone();
if !file_classes.is_empty() {
classes_by_file.insert(file_path, file_classes);
}
}
Err(e) => {
eprintln!("Warning: Failed to parse file {:?}: {}", file_path, e);
stats.files_skipped += 1;
}
}
}
let classes = self.parser.get_classes().clone();
let responsive_classes = self.parser.get_all_responsive_classes().clone();
let conditional_classes = self.parser.get_all_conditional_classes().clone();
stats.total_classes = classes.len();
stats.unique_classes = classes.len();
stats.duration_ms = start_time.elapsed().as_millis() as u64;
Ok(ScanResults {
classes,
responsive_classes,
conditional_classes,
classes_by_file,
stats,
})
}
pub fn scan_files(&mut self, files: &[PathBuf]) -> Result<ScanResults> {
let start_time = std::time::Instant::now();
let mut stats = ScanStats {
files_scanned: 0,
files_skipped: 0,
total_classes: 0,
unique_classes: 0,
duration_ms: 0,
total_file_size: 0,
};
let mut classes_by_file = HashMap::new();
for file_path in files {
if !self.should_scan_file(file_path) {
stats.files_skipped += 1;
continue;
}
if let Some(max_size) = self.config.max_file_size {
if let Ok(metadata) = fs::metadata(file_path) {
if metadata.len() > max_size as u64 {
stats.files_skipped += 1;
continue;
}
stats.total_file_size += metadata.len();
}
}
match self.parser.parse_file(file_path) {
Ok(()) => {
stats.files_scanned += 1;
let file_classes: HashSet<String> = self.parser.get_classes().clone();
if !file_classes.is_empty() {
classes_by_file.insert(file_path.clone(), file_classes);
}
}
Err(e) => {
eprintln!("Warning: Failed to parse file {:?}: {}", file_path, e);
stats.files_skipped += 1;
}
}
}
let classes = self.parser.get_classes().clone();
let responsive_classes = self.parser.get_all_responsive_classes().clone();
let conditional_classes = self.parser.get_all_conditional_classes().clone();
stats.total_classes = classes.len();
stats.unique_classes = classes.len();
stats.duration_ms = start_time.elapsed().as_millis() as u64;
Ok(ScanResults {
classes,
responsive_classes,
conditional_classes,
classes_by_file,
stats,
})
}
pub fn get_config(&self) -> &ScanConfig {
&self.config
}
pub fn set_config(&mut self, config: ScanConfig) {
self.config = config;
}
pub fn clear(&mut self) {
self.parser.clear();
}
fn find_files_to_scan(&self, path: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if path.is_file() {
if self.should_scan_file(path) {
files.push(path.to_path_buf());
}
} else if path.is_dir() {
self.scan_directory_recursive(path, &mut files)?;
} else {
return Err(TailwindError::build(format!(
"Path {:?} is neither a file nor a directory",
path
)));
}
Ok(files)
}
fn scan_directory_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
let entries = fs::read_dir(dir).map_err(|e| {
TailwindError::build(format!("Failed to read directory {:?}: {}", dir, e))
})?;
for entry in entries {
let entry = entry.map_err(|e| {
TailwindError::build(format!("Failed to read directory entry: {}", e))
})?;
let path = entry.path();
if path.is_dir() {
if self.should_exclude_directory(&path) {
continue;
}
self.scan_directory_recursive(&path, files)?;
} else if path.is_file()
&& self.should_scan_file(&path) {
files.push(path);
}
}
Ok(())
}
fn should_scan_file(&self, path: &Path) -> bool {
if let Some(extension) = path.extension() {
if let Some(ext_str) = extension.to_str() {
if !self.config.extensions.contains(&ext_str.to_string()) {
return false;
}
} else {
return false;
}
} else {
return false;
}
if let Some(file_name) = path.file_name() {
if let Some(name_str) = file_name.to_str() {
for pattern in &self.config.exclude_patterns {
if self.matches_pattern(name_str, pattern) {
return false;
}
}
}
}
true
}
fn should_exclude_directory(&self, path: &Path) -> bool {
if let Some(dir_name) = path.file_name() {
if let Some(name_str) = dir_name.to_str() {
for exclude_dir in &self.config.exclude_dirs {
if let Some(exclude_name) = exclude_dir.file_name() {
if let Some(exclude_str) = exclude_name.to_str() {
if name_str == exclude_str {
return true;
}
}
}
}
}
}
false
}
fn matches_pattern(&self, text: &str, pattern: &str) -> bool {
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
text.starts_with(prefix) && text.ends_with(suffix)
} else {
false
}
} else {
text == pattern
}
}
}
impl Default for ClassScanner {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_scanner_creation() {
let scanner = ClassScanner::new();
assert_eq!(scanner.get_config().extensions, vec!["rs"]);
}
#[test]
fn test_custom_config() {
let config = ScanConfig {
extensions: vec!["rs".to_string(), "toml".to_string()],
include_dirs: vec![],
exclude_dirs: vec![],
exclude_patterns: vec![],
max_file_size: Some(1024),
follow_symlinks: true,
};
let scanner = ClassScanner::with_config(config);
assert_eq!(scanner.get_config().extensions.len(), 2);
assert_eq!(scanner.get_config().max_file_size, Some(1024));
}
#[test]
fn test_scan_single_file() {
let mut scanner = ClassScanner::new();
let temp_file = std::env::temp_dir().join("test_scan.rs");
let content = r#"
use tailwind_rs_core::ClassBuilder;
fn test() -> String {
ClassBuilder::new()
.class("px-4")
.class("py-2")
.class("bg-blue-500")
.build_string()
}
"#;
fs::write(&temp_file, content).unwrap();
let results = scanner.scan_files(&[temp_file.clone()]).unwrap();
assert_eq!(results.stats.files_scanned, 1);
assert_eq!(results.stats.files_skipped, 0);
fs::remove_file(&temp_file).unwrap();
}
#[test]
fn test_scan_directory() {
let mut scanner = ClassScanner::new();
let temp_dir = std::env::temp_dir().join("test_scan_dir");
fs::create_dir_all(&temp_dir).unwrap();
let file1 = temp_dir.join("file1.rs");
let file2 = temp_dir.join("file2.rs");
let ignored = temp_dir.join("ignored_test.rs");
fs::write(&file1, r#"ClassBuilder::new().class("p-4").build_string()"#).unwrap();
fs::write(&file2, r#"ClassBuilder::new().class("m-2").build_string()"#).unwrap();
fs::write(
&ignored,
r#"ClassBuilder::new().class("ignored").build_string()"#,
)
.unwrap();
let results = scanner.scan_directory(&temp_dir).unwrap();
assert!(!results.classes.contains("ignored")); assert_eq!(results.stats.files_scanned, 2);
fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_clear() {
let mut scanner = ClassScanner::new();
let temp_file = std::env::temp_dir().join("test_clear.rs");
let content = r#"ClassBuilder::new().class("test-class").build_string()"#;
fs::write(&temp_file, content).unwrap();
scanner.scan_files(&[temp_file.clone()]).unwrap();
scanner.clear();
assert!(scanner.parser.get_classes().is_empty());
fs::remove_file(&temp_file).unwrap();
}
#[test]
fn test_pattern_matching() {
let scanner = ClassScanner::new();
assert!(scanner.matches_pattern("my_test.rs", "*_test.rs"));
assert!(scanner.matches_pattern("my_tests.rs", "*_tests.rs"));
assert!(!scanner.matches_pattern("normal_file.rs", "*_test.rs"));
assert!(scanner.matches_pattern("exact.rs", "exact.rs"));
}
}