1use std::{
17 error::Error,
18 fmt, fs,
19 path::{Path, PathBuf},
20 time::SystemTime,
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ProjectType {
30 Rust,
32 Node,
34 Python,
36 DotNet,
38 Unity,
40 Unreal,
42 Maven,
44 Gradle,
46 CMake,
48 HaskellStack,
50 ScalaSBT,
52 Composer,
54 Dart,
56 Elixir,
58 Swift,
60 Zig,
62 Godot,
64 Jupyter,
66}
67
68impl ProjectType {
69 pub fn name(&self) -> &'static str {
71 match self {
72 Self::Rust => "Rust",
73 Self::Node => "Node.js",
74 Self::Python => "Python",
75 Self::DotNet => ".NET",
76 Self::Unity => "Unity",
77 Self::Unreal => "Unreal Engine",
78 Self::Maven => "Maven",
79 Self::Gradle => "Gradle",
80 Self::CMake => "CMake",
81 Self::HaskellStack => "Haskell Stack",
82 Self::ScalaSBT => "Scala SBT",
83 Self::Composer => "PHP Composer",
84 Self::Dart => "Dart/Flutter",
85 Self::Elixir => "Elixir",
86 Self::Swift => "Swift",
87 Self::Zig => "Zig",
88 Self::Godot => "Godot",
89 Self::Jupyter => "Jupyter",
90 }
91 }
92
93 pub fn artifact_directories(&self) -> &[&str] {
95 match self {
96 Self::Rust => &["target", ".xwin-cache"],
97 Self::Node => &[
98 "node_modules",
99 ".next",
100 ".nuxt",
101 "dist",
102 "build",
103 ".angular",
104 ],
105 Self::Python => &[
106 "__pycache__",
107 ".pytest_cache",
108 ".mypy_cache",
109 ".ruff_cache",
110 ".tox",
111 ".nox",
112 ".venv",
113 "venv",
114 ".hypothesis",
115 "__pypackages__",
116 "*.egg-info",
117 ],
118 Self::DotNet => &["bin", "obj"],
119 Self::Unity => &[
120 "Library",
121 "Temp",
122 "Obj",
123 "Logs",
124 "MemoryCaptures",
125 "Build",
126 "Builds",
127 ],
128 Self::Unreal => &[
129 "Binaries",
130 "Build",
131 "Saved",
132 "Intermediate",
133 "DerivedDataCache",
134 ],
135 Self::Maven => &["target"],
136 Self::Gradle => &["build", ".gradle"],
137 Self::CMake => &["build", "cmake-build-debug", "cmake-build-release"],
138 Self::HaskellStack => &[".stack-work"],
139 Self::ScalaSBT => &["target", "project/target"],
140 Self::Composer => &["vendor"],
141 Self::Dart => &["build", ".dart_tool"],
142 Self::Elixir => &["_build", ".elixir-tools", ".elixir_ls", ".lexical"],
143 Self::Swift => &[".build", ".swiftpm"],
144 Self::Zig => &["zig-cache", "zig-out"],
145 Self::Godot => &[".godot"],
146 Self::Jupyter => &[".ipynb_checkpoints"],
147 }
148 }
149
150 pub fn detect_from_directory(path: &Path) -> Option<Self> {
152 let entries: Vec<_> = fs::read_dir(path).ok()?.filter_map(|e| e.ok()).collect();
154
155 for entry in &entries {
157 let file_name = entry.file_name();
158 let file_name_str = file_name.to_string_lossy();
159
160 match file_name_str.as_ref() {
162 "Cargo.toml" => return Some(Self::Rust),
163 "package.json" => return Some(Self::Node),
164 "pom.xml" => return Some(Self::Maven),
165 "build.gradle" | "build.gradle.kts" => return Some(Self::Gradle),
166 "CMakeLists.txt" => return Some(Self::CMake),
167 "stack.yaml" => return Some(Self::HaskellStack),
168 "build.sbt" => return Some(Self::ScalaSBT),
169 "composer.json" => return Some(Self::Composer),
170 "pubspec.yaml" => return Some(Self::Dart),
171 "mix.exs" => return Some(Self::Elixir),
172 "Package.swift" => return Some(Self::Swift),
173 "build.zig" => return Some(Self::Zig),
174 "project.godot" => return Some(Self::Godot),
175 "Assembly-CSharp.csproj" => return Some(Self::Unity),
176 _ => {}
177 }
178
179 if file_name_str.ends_with(".uproject") {
181 return Some(Self::Unreal);
182 }
183 if file_name_str.ends_with(".csproj") || file_name_str.ends_with(".fsproj") {
184 if Self::has_file(path, "project.godot") {
186 return Some(Self::Godot);
187 } else if Self::has_file(path, "Assembly-CSharp.csproj") {
188 return Some(Self::Unity);
189 } else {
190 return Some(Self::DotNet);
191 }
192 }
193 if file_name_str.ends_with(".ipynb") {
194 return Some(Self::Jupyter);
195 }
196 if file_name_str.ends_with(".py") {
197 if Self::has_any_artifact(path, Self::Python.artifact_directories()) {
199 return Some(Self::Python);
200 }
201 }
202 }
203
204 None
205 }
206
207 fn has_file(dir: &Path, file_name: &str) -> bool {
209 dir.join(file_name).exists()
210 }
211
212 fn has_any_artifact(dir: &Path, artifacts: &[&str]) -> bool {
214 artifacts.iter().any(|artifact| {
215 let artifact_path = dir.join(artifact);
216 artifact_path.exists()
217 })
218 }
219}
220
221#[derive(Debug, Clone)]
227pub struct Project {
228 pub project_type: ProjectType,
230 pub path: PathBuf,
232}
233
234impl Project {
235 pub fn new(project_type: ProjectType, path: PathBuf) -> Self {
237 Self { project_type, path }
238 }
239
240 pub fn display_name(&self) -> String {
242 self.path
243 .file_name()
244 .and_then(|n| n.to_str())
245 .unwrap_or("Unknown")
246 .to_string()
247 }
248
249 pub fn calculate_artifact_size(&self, options: &ScanOptions) -> u64 {
251 let mut total_size = 0u64;
252
253 for artifact_dir in self.project_type.artifact_directories() {
254 let artifact_path = self.path.join(artifact_dir);
255 if artifact_path.exists() {
256 total_size += calculate_directory_size(&artifact_path, options);
257 }
258 }
259
260 total_size
261 }
262
263 pub fn last_modified(&self, options: &ScanOptions) -> Result<SystemTime, std::io::Error> {
265 let metadata = fs::metadata(&self.path)?;
266 let mut most_recent = metadata.modified()?;
267
268 let walker = walkdir::WalkDir::new(&self.path)
270 .follow_links(options.follow_symlinks)
271 .same_file_system(options.same_filesystem);
272
273 for entry in walker.into_iter().filter_map(|e| e.ok()) {
274 if let Ok(metadata) = entry.metadata() {
275 if let Ok(modified) = metadata.modified() {
276 if modified > most_recent {
277 most_recent = modified;
278 }
279 }
280 }
281 }
282
283 Ok(most_recent)
284 }
285
286 pub fn clean(&self) -> Result<u64, CleanError> {
288 let mut total_deleted = 0u64;
289 let mut errors = Vec::new();
290
291 for artifact_dir in self.project_type.artifact_directories() {
292 let artifact_path = self.path.join(artifact_dir);
293
294 if !artifact_path.exists() {
295 continue;
296 }
297
298 let size = calculate_directory_size(&artifact_path, &ScanOptions::default());
300
301 match fs::remove_dir_all(&artifact_path) {
303 Ok(_) => {
304 total_deleted += size;
305 }
306 Err(e) => {
307 errors.push((artifact_path.clone(), e));
308 }
309 }
310 }
311
312 if errors.is_empty() {
313 Ok(total_deleted)
314 } else {
315 Err(CleanError::PartialFailure {
316 deleted: total_deleted,
317 errors,
318 })
319 }
320 }
321}
322
323#[derive(Debug, Clone)]
329pub struct ScanOptions {
330 pub follow_symlinks: bool,
332 pub same_filesystem: bool,
334 pub min_age_seconds: u64,
336}
337
338impl Default for ScanOptions {
339 fn default() -> Self {
340 Self {
341 follow_symlinks: false,
342 same_filesystem: true,
343 min_age_seconds: 0,
344 }
345 }
346}
347
348pub fn scan_directory<P: AsRef<Path>>(
354 path: P,
355 options: &ScanOptions,
356) -> impl Iterator<Item = Result<Project, ScanError>> {
357 let path = path.as_ref().to_path_buf();
358 let options = options.clone();
359
360 let walker = walkdir::WalkDir::new(&path)
362 .follow_links(options.follow_symlinks)
363 .same_file_system(options.same_filesystem)
364 .into_iter();
365
366 walker.filter_map(move |entry| {
368 let entry = match entry {
369 Ok(e) => e,
370 Err(e) => return Some(Err(ScanError::WalkError(e))),
371 };
372
373 if !entry.file_type().is_dir() {
375 return None;
376 }
377
378 if entry.file_name().to_string_lossy().starts_with('.') {
380 return None;
381 }
382
383 let dir_path = entry.path();
384
385 if let Some(project_type) = ProjectType::detect_from_directory(dir_path) {
387 let project = Project::new(project_type, dir_path.to_path_buf());
388
389 if options.min_age_seconds > 0 {
391 if let Ok(last_modified) = project.last_modified(&options) {
392 if let Ok(elapsed) = last_modified.elapsed() {
393 if elapsed.as_secs() < options.min_age_seconds {
394 return None; }
396 }
397 }
398 }
399
400 return Some(Ok(project));
401 }
402
403 None
404 })
405}
406
407pub fn calculate_directory_size<P: AsRef<Path>>(path: P, options: &ScanOptions) -> u64 {
409 let walker = walkdir::WalkDir::new(path.as_ref())
410 .follow_links(options.follow_symlinks)
411 .same_file_system(options.same_filesystem);
412
413 walker
414 .into_iter()
415 .filter_map(|e| e.ok())
416 .filter(|e| e.file_type().is_file())
417 .filter_map(|e| e.metadata().ok())
418 .map(|m| m.len())
419 .sum()
420}
421
422pub fn format_size(bytes: u64) -> String {
428 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
429 const THRESHOLD: f64 = 1024.0;
430
431 if bytes == 0 {
432 return "0 B".to_string();
433 }
434
435 let bytes_f64 = bytes as f64;
436 let unit_index = (bytes_f64.log(THRESHOLD).floor() as usize).min(UNITS.len() - 1);
437 let size = bytes_f64 / THRESHOLD.powi(unit_index as i32);
438
439 format!("{:.1} {}", size, UNITS[unit_index])
440}
441
442pub fn format_elapsed_time(seconds: u64) -> String {
444 const MINUTE: u64 = 60;
445 const HOUR: u64 = MINUTE * 60;
446 const DAY: u64 = HOUR * 24;
447 const WEEK: u64 = DAY * 7;
448 const MONTH: u64 = DAY * 30;
449 const YEAR: u64 = DAY * 365;
450
451 let (value, unit) = match seconds {
452 s if s < MINUTE => (s, "second"),
453 s if s < HOUR => (s / MINUTE, "minute"),
454 s if s < DAY => (s / HOUR, "hour"),
455 s if s < WEEK => (s / DAY, "day"),
456 s if s < MONTH => (s / WEEK, "week"),
457 s if s < YEAR => (s / MONTH, "month"),
458 s => (s / YEAR, "year"),
459 };
460
461 let plural = if value == 1 { "" } else { "s" };
462 format!("{} {}{} ago", value, unit, plural)
463}
464
465#[derive(Debug)]
471pub enum ScanError {
472 WalkError(walkdir::Error),
474 IoError(std::io::Error),
476}
477
478impl fmt::Display for ScanError {
479 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
480 match self {
481 Self::WalkError(e) => write!(f, "Walk error: {}", e),
482 Self::IoError(e) => write!(f, "IO error: {}", e),
483 }
484 }
485}
486
487impl Error for ScanError {}
488
489impl From<walkdir::Error> for ScanError {
490 fn from(e: walkdir::Error) -> Self {
491 Self::WalkError(e)
492 }
493}
494
495impl From<std::io::Error> for ScanError {
496 fn from(e: std::io::Error) -> Self {
497 Self::IoError(e)
498 }
499}
500
501#[derive(Debug)]
503pub enum CleanError {
504 IoError(std::io::Error),
506 PartialFailure {
508 deleted: u64,
509 errors: Vec<(PathBuf, std::io::Error)>,
510 },
511}
512
513impl fmt::Display for CleanError {
514 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
515 match self {
516 Self::IoError(e) => write!(f, "Clean error: {}", e),
517 Self::PartialFailure { deleted, errors } => {
518 write!(
519 f,
520 "Partially cleaned ({} bytes), {} errors occurred",
521 deleted,
522 errors.len()
523 )
524 }
525 }
526 }
527}
528
529impl Error for CleanError {}
530
531impl From<std::io::Error> for CleanError {
532 fn from(e: std::io::Error) -> Self {
533 Self::IoError(e)
534 }
535}
536
537#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_format_size() {
547 assert_eq!(format_size(0), "0 B");
548 assert_eq!(format_size(512), "512.0 B");
549 assert_eq!(format_size(1024), "1.0 KB");
550 assert_eq!(format_size(1536), "1.5 KB");
551 assert_eq!(format_size(1_048_576), "1.0 MB");
552 assert_eq!(format_size(1_073_741_824), "1.0 GB");
553 }
554
555 #[test]
556 fn test_format_elapsed_time() {
557 assert_eq!(format_elapsed_time(0), "0 seconds ago");
558 assert_eq!(format_elapsed_time(1), "1 second ago");
559 assert_eq!(format_elapsed_time(59), "59 seconds ago");
560 assert_eq!(format_elapsed_time(60), "1 minute ago");
561 assert_eq!(format_elapsed_time(3600), "1 hour ago");
562 assert_eq!(format_elapsed_time(86400), "1 day ago");
563 }
564
565 #[test]
566 fn test_project_type_names() {
567 assert_eq!(ProjectType::Rust.name(), "Rust");
568 assert_eq!(ProjectType::Node.name(), "Node.js");
569 assert_eq!(ProjectType::Python.name(), "Python");
570 }
571}