use crate::cli::{Cli, SortBy};
use crate::config::Config;
use crate::project::{ArtifactType, BuildArtifact, RustProject};
use crate::utils;
use anyhow::Result;
use chrono::{DateTime, Utc};
use colored::Colorize;
use rayon::prelude::*;
use std::fs;
use std::path::Path;
use tokio::task;
use walkdir::WalkDir;
#[derive(Debug)]
pub struct ProjectScanner {
cli: Cli,
config: Config,
}
impl ProjectScanner {
pub fn new(cli: Cli, config: Config) -> Self {
Self { cli, config }
}
pub async fn scan(&self) -> Result<Vec<RustProject>> {
let search_dir = self.cli.get_search_directory();
let excluded_dirs = self.cli.get_excluded_dirs();
println!("🔍 Scanning for Rust projects in: {}", search_dir.display());
let enable_parallel = self.config.performance.enable_parallel_scanning.unwrap_or(true);
let max_threads = self.config.performance.max_parallel_threads.unwrap_or(0);
if enable_parallel && max_threads > 0 {
rayon::ThreadPoolBuilder::new()
.num_threads(max_threads as usize)
.build_global()
.unwrap_or_else(|_| {
eprintln!("Warning: Failed to set custom thread pool size, using default");
});
}
let cli_clone = self.cli.clone();
let config_clone = self.config.clone();
let projects = task::spawn_blocking(move || {
if enable_parallel {
Self::find_rust_projects_parallel(&search_dir, &excluded_dirs, &cli_clone, &config_clone)
} else {
Self::find_rust_projects_sequential(&search_dir, &excluded_dirs, &cli_clone)
}
}).await??;
println!("📦 Found {} Rust projects", projects.len());
Ok(projects)
}
fn find_rust_projects_sequential(
search_dir: &Path,
excluded_dirs: &[String],
cli: &Cli
) -> Result<Vec<RustProject>> {
let mut projects = Vec::new();
let mut processed_paths = std::collections::HashSet::new();
for entry in WalkDir::new(search_dir)
.follow_links(false)
.max_depth(if cli.full { 10 } else { 5 })
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if Self::is_excluded_path(path, excluded_dirs, cli.exclude_hidden) {
continue;
}
if path.file_name() == Some(std::ffi::OsStr::new("Cargo.toml")) {
let project_dir = path.parent().unwrap();
if processed_paths.contains(project_dir) {
continue;
}
processed_paths.insert(project_dir.to_path_buf());
if let Ok(project) = Self::analyze_rust_project(project_dir, cli) {
projects.push(project);
}
}
}
Self::sort_projects(&mut projects, &cli.get_sort_by(), cli.gb);
Ok(projects)
}
fn find_rust_projects_parallel(
search_dir: &Path,
excluded_dirs: &[String],
cli: &Cli,
config: &Config
) -> Result<Vec<RustProject>> {
let chunk_size = config.performance.chunk_size.unwrap_or(1000) as usize;
let cargo_tomls: Vec<_> = WalkDir::new(search_dir)
.follow_links(false)
.max_depth(if cli.full { 10 } else { 5 })
.into_iter()
.filter_map(|e| e.ok())
.filter(|entry| {
let path = entry.path();
!Self::is_excluded_path(path, excluded_dirs, cli.exclude_hidden) &&
path.file_name() == Some(std::ffi::OsStr::new("Cargo.toml"))
})
.collect();
let all_projects: Vec<RustProject> = cargo_tomls
.par_chunks(chunk_size)
.map(|chunk| {
let mut chunk_projects = Vec::new();
let mut seen_paths = std::collections::HashSet::new();
for entry in chunk {
let path = entry.path();
if let Some(project_dir) = path.parent() {
if !seen_paths.insert(project_dir.to_path_buf()) {
continue;
}
if let Ok(project) = Self::analyze_rust_project(project_dir, cli) {
chunk_projects.push(project);
}
}
}
chunk_projects
})
.flatten()
.collect();
let mut unique_projects = Vec::new();
let mut seen_paths = std::collections::HashSet::new();
for project in all_projects {
if seen_paths.insert(project.path.clone()) {
unique_projects.push(project);
}
}
Self::sort_projects(&mut unique_projects, &cli.get_sort_by(), cli.gb);
Ok(unique_projects)
}
fn is_excluded_path(path: &Path, excluded_dirs: &[String], exclude_hidden: bool) -> bool {
for component in path.components() {
let comp_str = component.as_os_str().to_string_lossy();
if excluded_dirs.iter().any(|excluded| comp_str.contains(excluded)) {
return true;
}
if exclude_hidden && comp_str.starts_with('.') {
return true;
}
}
false
}
fn analyze_rust_project(project_dir: &Path, cli: &Cli) -> Result<RustProject> {
let cargo_toml_path = project_dir.join("Cargo.toml");
let cargo_lock_path = project_dir.join("Cargo.lock");
let cargo_toml_content = fs::read_to_string(&cargo_toml_path)?;
let project_name = Self::extract_project_name(&cargo_toml_content)
.unwrap_or_else(|| {
project_dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
let target_dir = project_dir.join(&cli.get_target_name());
let (target_size, target_exists) = if target_dir.exists() {
(utils::calculate_dir_size(&target_dir)?, true)
} else {
(0, false)
};
let last_modified = Self::get_last_modified_time(project_dir)?;
let build_artifacts = if target_exists {
Self::analyze_build_artifacts(&target_dir)?
} else {
Vec::new()
};
let cargo_cache_size = if cli.include_cargo_cache {
Self::calculate_cargo_cache_size()?
} else {
0
};
let dependencies_count = Self::count_dependencies(&cargo_toml_content);
Ok(RustProject {
path: project_dir.to_path_buf(),
name: project_name,
target_dir: if target_exists { Some(target_dir) } else { None },
target_size,
last_modified,
workspace_root: Self::is_workspace_root(&cargo_toml_content),
has_lock_file: cargo_lock_path.exists(),
dependencies_count,
build_artifacts,
cargo_cache_size,
})
}
fn extract_project_name(cargo_toml: &str) -> Option<String> {
for line in cargo_toml.lines() {
if line.trim().starts_with("name") {
if let Some(name_part) = line.split('=').nth(1) {
return Some(
name_part
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string()
);
}
}
}
None
}
fn get_last_modified_time(project_dir: &Path) -> Result<Option<DateTime<Utc>>> {
let mut latest = None;
let files_to_check = ["Cargo.toml", "Cargo.lock", "src/main.rs", "src/lib.rs"];
for file in &files_to_check {
let file_path = project_dir.join(file);
if let Ok(metadata) = fs::metadata(&file_path) {
if let Ok(modified) = metadata.modified() {
let datetime: DateTime<Utc> = modified.into();
latest = Some(latest.map_or(datetime, |prev: DateTime<Utc>| prev.max(datetime)));
}
}
}
Ok(latest)
}
fn analyze_build_artifacts(target_dir: &Path) -> Result<Vec<BuildArtifact>> {
let mut artifacts = Vec::new();
if !target_dir.exists() {
return Ok(artifacts);
}
for entry in WalkDir::new(target_dir).max_depth(3).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
let artifact_type = match dir_name.as_ref() {
"debug" | "release" => ArtifactType::Target,
"incremental" => ArtifactType::IncrementalCompilation,
"deps" => ArtifactType::Dependencies,
"examples" => ArtifactType::Examples,
_ => continue,
};
let size = utils::calculate_dir_size(path).unwrap_or(0);
let last_modified = fs::metadata(path)
.and_then(|m| m.modified())
.map(DateTime::<Utc>::from)
.ok();
artifacts.push(BuildArtifact {
path: path.to_path_buf(),
artifact_type,
size,
last_modified,
});
}
}
Ok(artifacts)
}
fn calculate_cargo_cache_size() -> Result<u64> {
let mut total_size = 0u64;
if let Some(home) = dirs::home_dir() {
let cargo_dir = home.join(".cargo");
let registry_dir = cargo_dir.join("registry");
if registry_dir.exists() {
total_size += utils::calculate_dir_size(®istry_dir)?;
}
let git_dir = cargo_dir.join("git");
if git_dir.exists() {
total_size += utils::calculate_dir_size(&git_dir)?;
}
}
Ok(total_size)
}
fn count_dependencies(cargo_toml: &str) -> usize {
let mut in_dependencies = false;
let mut count = 0;
for line in cargo_toml.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_dependencies = trimmed.starts_with("[dependencies")
|| trimmed.starts_with("[dev-dependencies")
|| trimmed.starts_with("[build-dependencies");
continue;
}
if in_dependencies && !trimmed.is_empty() && !trimmed.starts_with('#') {
count += 1;
}
}
count
}
fn is_workspace_root(cargo_toml: &str) -> bool {
cargo_toml.contains("[workspace]")
}
fn sort_projects(projects: &mut Vec<RustProject>, sort_by: &SortBy, _use_gb: bool) {
match sort_by {
SortBy::Size => {
projects.sort_by(|a, b| b.total_cleanable_size().cmp(&a.total_cleanable_size()));
}
SortBy::Path => {
projects.sort_by(|a, b| a.path.cmp(&b.path));
}
SortBy::LastMod => {
projects.sort_by(|a, b| {
match (a.last_modified, b.last_modified) {
(Some(a_time), Some(b_time)) => b_time.cmp(&a_time),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
});
}
}
}
pub async fn print_projects(&self, projects: &[RustProject]) -> Result<()> {
if projects.is_empty() {
println!("🚫 No Rust projects found.");
return Ok(());
}
println!("\n📋 Found Rust Projects:\n");
println!(
"{:<30} {:<15} {:<20} {:<15} {:<10}",
"Project Name".bold(),
"Size".bold(),
"Path".bold(),
"Last Modified".bold(),
"Status".bold()
);
println!("{}", "─".repeat(100));
for project in projects {
let size_str = project.format_size(self.cli.gb);
let path_str = project.path.display().to_string();
let path_display = if path_str.len() > 18 {
format!("...{}", &path_str[path_str.len() - 15..])
} else {
path_str
};
let last_mod = project
.last_modified
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "Unknown".to_string());
let status = if project.is_likely_active() {
"Active".green()
} else {
"Stale".yellow()
};
let warning = if !project.is_likely_active() && project.total_cleanable_size() == 0 {
""
} else if !project.target_dir.is_some() {
" (no target)"
} else {
""
};
println!(
"{:<30} {:<15} {:<20} {:<15} {:<10}{}",
project.name,
size_str.cyan(),
path_display,
last_mod,
status,
warning.red()
);
}
let total_size: u64 = projects.iter().map(|p| p.total_cleanable_size()).sum();
let total_size_str = if self.cli.gb {
format!("{:.2} GB", total_size as f64 / (1024.0 * 1024.0 * 1024.0))
} else {
format!("{:.2} MB", total_size as f64 / (1024.0 * 1024.0))
};
println!("\n💾 Total cleanable space: {}", total_size_str.bold().green());
Ok(())
}
}