1use super::{Recommendation, RecommendationKind, RiskLevel};
7use crate::cleaners::calculate_dir_size;
8use crate::error::Result;
9use rayon::prelude::*;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::time::SystemTime;
13use walkdir::WalkDir;
14
15pub struct StaleProjectFinder {
17 pub stale_threshold_days: u64,
19 pub min_project_size: u64,
21}
22
23impl Default for StaleProjectFinder {
24 fn default() -> Self {
25 Self {
26 stale_threshold_days: 180, min_project_size: 100_000_000, }
29 }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ProjectType {
35 Node,
36 Rust,
37 Python,
38 Go,
39 Java,
40 Swift,
41 Ruby,
42 Unknown,
43}
44
45impl ProjectType {
46 pub fn icon(&self) -> &'static str {
48 match self {
49 Self::Node => "📦",
50 Self::Rust => "🦀",
51 Self::Python => "🐍",
52 Self::Go => "🐹",
53 Self::Java => "☕",
54 Self::Swift => "🍎",
55 Self::Ruby => "💎",
56 Self::Unknown => "📁",
57 }
58 }
59
60 pub fn name(&self) -> &'static str {
62 match self {
63 Self::Node => "Node.js",
64 Self::Rust => "Rust",
65 Self::Python => "Python",
66 Self::Go => "Go",
67 Self::Java => "Java",
68 Self::Swift => "Swift",
69 Self::Ruby => "Ruby",
70 Self::Unknown => "Unknown",
71 }
72 }
73
74 pub fn cleanable_dirs(&self) -> &[&str] {
76 match self {
77 Self::Node => &["node_modules", ".next", "dist", "build"],
78 Self::Rust => &["target"],
79 Self::Python => &["venv", ".venv", "__pycache__", ".pytest_cache"],
80 Self::Go => &[],
81 Self::Java => &["target", "build", ".gradle"],
82 Self::Swift => &["DerivedData", ".build", "Pods"],
83 Self::Ruby => &["vendor/bundle", ".bundle"],
84 Self::Unknown => &[],
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
91pub struct StaleProject {
92 pub path: PathBuf,
94 pub project_type: ProjectType,
96 pub days_stale: u64,
98 pub total_size: u64,
100 pub cleanable_size: u64,
102 pub last_activity: Option<String>,
104}
105
106impl StaleProjectFinder {
107 pub fn new() -> Self {
109 Self::default()
110 }
111
112 pub fn with_threshold(days: u64) -> Self {
114 Self {
115 stale_threshold_days: days,
116 ..Self::default()
117 }
118 }
119
120 pub fn scan(&self, root: &Path, max_depth: usize) -> Result<Vec<Recommendation>> {
122 let projects = self.find_projects(root, max_depth)?;
123
124 let recommendations: Vec<Recommendation> = projects
125 .par_iter()
126 .filter_map(|project_path| self.analyze_project(project_path).ok())
127 .flatten()
128 .collect();
129
130 Ok(recommendations)
131 }
132
133 fn find_projects(&self, root: &Path, max_depth: usize) -> Result<Vec<PathBuf>> {
135 let mut projects = Vec::new();
136
137 let markers = [
139 "package.json",
140 "Cargo.toml",
141 "pyproject.toml",
142 "setup.py",
143 "requirements.txt",
144 "go.mod",
145 "pom.xml",
146 "build.gradle",
147 "Package.swift",
148 "Gemfile",
149 ];
150
151 for entry in WalkDir::new(root)
152 .max_depth(max_depth)
153 .follow_links(false)
154 .into_iter()
155 .filter_entry(|e| {
156 let name = e.file_name().to_string_lossy();
157 name != "node_modules" &&
159 name != ".cargo" &&
160 name != "target" &&
161 name != "venv" &&
162 name != ".venv" &&
163 name != ".git" &&
164 name != "vendor"
165 })
166 .filter_map(|e| e.ok())
167 {
168 if entry.file_type().is_file() {
169 let name = entry.file_name().to_string_lossy();
170 if markers.iter().any(|m| *m == name) {
171 if let Some(parent) = entry.path().parent() {
172 if !projects.contains(&parent.to_path_buf()) {
173 projects.push(parent.to_path_buf());
174 }
175 }
176 }
177 }
178 }
179
180 Ok(projects)
181 }
182
183 fn analyze_project(&self, project_path: &Path) -> Result<Vec<Recommendation>> {
185 let mut recommendations = Vec::new();
186
187 let project_type = self.detect_project_type(project_path);
189
190 let (days_stale, _last_activity) = self.get_last_activity(project_path);
192
193 if days_stale < self.stale_threshold_days {
195 return Ok(recommendations);
196 }
197
198 let (total_size, _) = calculate_dir_size(project_path)?;
200
201 if total_size < self.min_project_size {
202 return Ok(recommendations);
203 }
204
205 let cleanable_size = self.calculate_cleanable_size(project_path, project_type);
207
208 let project_name = project_path
209 .file_name()
210 .map(|n| n.to_string_lossy().to_string())
211 .unwrap_or_else(|| "Unknown".to_string());
212
213 let months = days_stale / 30;
214 let time_desc = if months >= 12 {
215 format!("{} years", months / 12)
216 } else {
217 format!("{} months", months)
218 };
219
220 recommendations.push(Recommendation {
221 kind: RecommendationKind::StaleProject,
222 title: format!(
223 "{} {} ({}) - {} stale",
224 project_type.icon(),
225 project_name,
226 format_size(total_size),
227 time_desc
228 ),
229 description: if cleanable_size > 0 {
230 format!(
231 "{} project not touched in {} days. {} in build artifacts can be cleaned.",
232 project_type.name(),
233 days_stale,
234 format_size(cleanable_size)
235 )
236 } else {
237 format!(
238 "{} project not touched in {} days. Consider archiving or deleting.",
239 project_type.name(),
240 days_stale
241 )
242 },
243 path: project_path.to_path_buf(),
244 potential_savings: cleanable_size,
245 fix_command: if cleanable_size > 0 {
246 Some(self.get_clean_command(project_type, project_path))
247 } else {
248 None
249 },
250 risk: if cleanable_size > 0 {
251 RiskLevel::Low
252 } else {
253 RiskLevel::High
254 },
255 });
256
257 Ok(recommendations)
258 }
259
260 fn detect_project_type(&self, path: &Path) -> ProjectType {
262 if path.join("package.json").exists() {
263 ProjectType::Node
264 } else if path.join("Cargo.toml").exists() {
265 ProjectType::Rust
266 } else if path.join("pyproject.toml").exists()
267 || path.join("setup.py").exists()
268 || path.join("requirements.txt").exists()
269 {
270 ProjectType::Python
271 } else if path.join("go.mod").exists() {
272 ProjectType::Go
273 } else if path.join("pom.xml").exists() || path.join("build.gradle").exists() {
274 ProjectType::Java
275 } else if path.join("Package.swift").exists() {
276 ProjectType::Swift
277 } else if path.join("Gemfile").exists() {
278 ProjectType::Ruby
279 } else {
280 ProjectType::Unknown
281 }
282 }
283
284 fn get_last_activity(&self, path: &Path) -> (u64, Option<String>) {
286 if path.join(".git").exists() {
288 if let Some((days, date)) = self.get_git_last_commit(path) {
289 return (days, Some(date));
290 }
291 }
292
293 self.get_filesystem_mtime(path)
295 }
296
297 fn get_git_last_commit(&self, path: &Path) -> Option<(u64, String)> {
299 let output = Command::new("git")
300 .args(["log", "-1", "--format=%ct"])
301 .current_dir(path)
302 .output()
303 .ok()?;
304
305 if !output.status.success() {
306 return None;
307 }
308
309 let timestamp_str = String::from_utf8_lossy(&output.stdout);
310 let timestamp: i64 = timestamp_str.trim().parse().ok()?;
311
312 let now = SystemTime::now()
313 .duration_since(SystemTime::UNIX_EPOCH)
314 .ok()?
315 .as_secs() as i64;
316
317 let days = ((now - timestamp) / 86400) as u64;
318
319 let date_output = Command::new("git")
321 .args(["log", "-1", "--format=%ci"])
322 .current_dir(path)
323 .output()
324 .ok()?;
325
326 let date = String::from_utf8_lossy(&date_output.stdout)
327 .trim()
328 .to_string();
329
330 Some((days, date))
331 }
332
333 fn get_filesystem_mtime(&self, path: &Path) -> (u64, Option<String>) {
335 let mut latest_mtime: Option<SystemTime> = None;
336
337 let key_files = ["package.json", "Cargo.toml", "pyproject.toml", "go.mod"];
339
340 for file in key_files {
341 let file_path = path.join(file);
342 if let Ok(meta) = std::fs::metadata(&file_path) {
343 if let Ok(mtime) = meta.modified() {
344 if latest_mtime.is_none() || mtime > latest_mtime.unwrap() {
345 latest_mtime = Some(mtime);
346 }
347 }
348 }
349 }
350
351 if let Some(mtime) = latest_mtime {
352 if let Ok(duration) = mtime.elapsed() {
353 let days = duration.as_secs() / 86400;
354 return (days, None);
355 }
356 }
357
358 (0, None)
359 }
360
361 fn calculate_cleanable_size(&self, path: &Path, project_type: ProjectType) -> u64 {
363 let mut total = 0u64;
364
365 for dir_name in project_type.cleanable_dirs() {
366 let dir_path = path.join(dir_name);
367 if dir_path.exists() {
368 if let Ok((size, _)) = calculate_dir_size(&dir_path) {
369 total += size;
370 }
371 }
372 }
373
374 total
375 }
376
377 fn get_clean_command(&self, project_type: ProjectType, path: &Path) -> String {
379 let path_str = path.to_string_lossy();
380 match project_type {
381 ProjectType::Node => format!("rm -rf {}/node_modules", path_str),
382 ProjectType::Rust => format!("cargo clean --manifest-path {}/Cargo.toml", path_str),
383 ProjectType::Python => format!("rm -rf {}/.venv {}/venv", path_str, path_str),
384 ProjectType::Java => format!("rm -rf {}/target {}/build", path_str, path_str),
385 ProjectType::Swift => format!("rm -rf {}/.build {}/DerivedData", path_str, path_str),
386 ProjectType::Ruby => format!("rm -rf {}/vendor/bundle", path_str),
387 _ => format!("# No automatic cleanup for {}", path_str),
388 }
389 }
390}
391
392fn format_size(bytes: u64) -> String {
394 super::format_size(bytes)
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn test_stale_finder_creation() {
403 let finder = StaleProjectFinder::new();
404 assert_eq!(finder.stale_threshold_days, 180);
405 }
406
407 #[test]
408 fn test_project_type_detection() {
409 let finder = StaleProjectFinder::new();
410 let project_type = finder.detect_project_type(Path::new("."));
412 println!("Detected project type: {:?}", project_type);
413 }
414
415 #[test]
416 fn test_stale_scan() {
417 let finder = StaleProjectFinder::with_threshold(30); if let Ok(recommendations) = finder.scan(Path::new("."), 2) {
419 println!("Found {} stale project recommendations", recommendations.len());
420 for rec in &recommendations {
421 println!(" {} - {}", rec.title, rec.description);
422 }
423 }
424 }
425}