Skip to main content

null_e/analysis/
stale.rs

1//! Stale project detection
2//!
3//! Finds development projects that haven't been touched in a long time.
4//! These are candidates for archiving or cleanup.
5
6use 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
15/// Stale project detector
16pub struct StaleProjectFinder {
17    /// Minimum days since last activity to consider stale
18    pub stale_threshold_days: u64,
19    /// Minimum project size to report (bytes)
20    pub min_project_size: u64,
21}
22
23impl Default for StaleProjectFinder {
24    fn default() -> Self {
25        Self {
26            stale_threshold_days: 180, // 6 months
27            min_project_size: 100_000_000, // 100MB
28        }
29    }
30}
31
32/// Project type detection
33#[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    /// Get icon for display
47    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    /// Get name for display
61    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    /// Get cleanable directories for this project type
75    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/// Information about a stale project
90#[derive(Debug, Clone)]
91pub struct StaleProject {
92    /// Project root path
93    pub path: PathBuf,
94    /// Detected project type
95    pub project_type: ProjectType,
96    /// Days since last activity
97    pub days_stale: u64,
98    /// Total project size
99    pub total_size: u64,
100    /// Size of cleanable artifacts
101    pub cleanable_size: u64,
102    /// Last activity date
103    pub last_activity: Option<String>,
104}
105
106impl StaleProjectFinder {
107    /// Create a new stale project finder
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Create with custom threshold
113    pub fn with_threshold(days: u64) -> Self {
114        Self {
115            stale_threshold_days: days,
116            ..Self::default()
117        }
118    }
119
120    /// Scan for stale projects
121    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    /// Find all project directories
134    fn find_projects(&self, root: &Path, max_depth: usize) -> Result<Vec<PathBuf>> {
135        let mut projects = Vec::new();
136
137        // Project markers to look for
138        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                // Skip common non-project directories
158                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    /// Analyze a single project
184    fn analyze_project(&self, project_path: &Path) -> Result<Vec<Recommendation>> {
185        let mut recommendations = Vec::new();
186
187        // Detect project type
188        let project_type = self.detect_project_type(project_path);
189
190        // Get last activity (from git or file system)
191        let (days_stale, _last_activity) = self.get_last_activity(project_path);
192
193        // Skip if not stale enough
194        if days_stale < self.stale_threshold_days {
195            return Ok(recommendations);
196        }
197
198        // Calculate project size
199        let (total_size, _) = calculate_dir_size(project_path)?;
200
201        if total_size < self.min_project_size {
202            return Ok(recommendations);
203        }
204
205        // Calculate cleanable size (build artifacts)
206        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    /// Detect project type from files
261    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    /// Get last activity date (prefer git, fall back to file mtime)
285    fn get_last_activity(&self, path: &Path) -> (u64, Option<String>) {
286        // Try git first
287        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        // Fall back to file system mtime
294        self.get_filesystem_mtime(path)
295    }
296
297    /// Get days since last git commit
298    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        // Get formatted date
320        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    /// Get days since last file modification
334    fn get_filesystem_mtime(&self, path: &Path) -> (u64, Option<String>) {
335        let mut latest_mtime: Option<SystemTime> = None;
336
337        // Check key files for mtime
338        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    /// Calculate size of cleanable artifacts
362    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    /// Get command to clean project artifacts
378    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
392/// Format bytes as human-readable size
393fn 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        // Test on current directory if it's a project
411        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); // 30 days for testing
418        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}