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 Go,
68 Ruby,
70 Terraform,
72 Docker,
74 Bazel,
76}
77
78impl ProjectType {
79 pub fn name(&self) -> &'static str {
81 match self {
82 Self::Rust => "Rust",
83 Self::Node => "Node.js",
84 Self::Python => "Python",
85 Self::DotNet => ".NET",
86 Self::Unity => "Unity",
87 Self::Unreal => "Unreal Engine",
88 Self::Maven => "Maven",
89 Self::Gradle => "Gradle",
90 Self::CMake => "CMake",
91 Self::HaskellStack => "Haskell Stack",
92 Self::ScalaSBT => "Scala SBT",
93 Self::Composer => "PHP Composer",
94 Self::Dart => "Dart/Flutter",
95 Self::Elixir => "Elixir",
96 Self::Swift => "Swift",
97 Self::Zig => "Zig",
98 Self::Godot => "Godot",
99 Self::Jupyter => "Jupyter",
100 Self::Go => "Go",
101 Self::Ruby => "Ruby",
102 Self::Terraform => "Terraform",
103 Self::Docker => "Docker",
104 Self::Bazel => "Bazel",
105 }
106 }
107
108 pub fn artifact_directories(&self) -> &[&str] {
110 match self {
111 Self::Rust => &["target", ".xwin-cache"],
112 Self::Node => &[
113 "node_modules",
114 ".next",
115 ".nuxt",
116 "dist",
117 "build",
118 ".angular",
119 ],
120 Self::Python => &[
121 "__pycache__",
122 ".pytest_cache",
123 ".mypy_cache",
124 ".ruff_cache",
125 ".tox",
126 ".nox",
127 ".venv",
128 "venv",
129 ".hypothesis",
130 "__pypackages__",
131 "*.egg-info",
132 ],
133 Self::DotNet => &["bin", "obj"],
134 Self::Unity => &[
135 "Library",
136 "Temp",
137 "Obj",
138 "Logs",
139 "MemoryCaptures",
140 "Build",
141 "Builds",
142 ],
143 Self::Unreal => &[
144 "Binaries",
145 "Build",
146 "Saved",
147 "Intermediate",
148 "DerivedDataCache",
149 ],
150 Self::Maven => &["target"],
151 Self::Gradle => &["build", ".gradle"],
152 Self::CMake => &["build", "cmake-build-debug", "cmake-build-release"],
153 Self::HaskellStack => &[".stack-work"],
154 Self::ScalaSBT => &["target", "project/target"],
155 Self::Composer => &["vendor"],
156 Self::Dart => &["build", ".dart_tool"],
157 Self::Elixir => &["_build", ".elixir-tools", ".elixir_ls", ".lexical"],
158 Self::Swift => &[".build", ".swiftpm"],
159 Self::Zig => &["zig-cache", "zig-out"],
160 Self::Godot => &[".godot"],
161 Self::Jupyter => &[".ipynb_checkpoints"],
162 Self::Go => &["vendor", "bin"],
163 Self::Ruby => &["vendor/bundle"],
164 Self::Terraform => &[".terraform", ".terraform.lock.hcl"],
165 Self::Docker => &[".docker"],
166 Self::Bazel => &["bazel-bin", "bazel-out", "bazel-testlogs", "bazel-*"],
167 }
168 }
169
170 pub fn detect_from_directory(path: &Path) -> Option<Self> {
172 let entries: Vec<_> = fs::read_dir(path).ok()?.filter_map(|e| e.ok()).collect();
174
175 for entry in &entries {
177 let file_name = entry.file_name();
178 let file_name_str = file_name.to_string_lossy();
179
180 match file_name_str.as_ref() {
182 "Cargo.toml" => return Some(Self::Rust),
183 "package.json" => return Some(Self::Node),
184 "pom.xml" => return Some(Self::Maven),
185 "build.gradle" | "build.gradle.kts" => return Some(Self::Gradle),
186 "CMakeLists.txt" => return Some(Self::CMake),
187 "stack.yaml" => return Some(Self::HaskellStack),
188 "build.sbt" => return Some(Self::ScalaSBT),
189 "composer.json" => return Some(Self::Composer),
190 "pubspec.yaml" => return Some(Self::Dart),
191 "mix.exs" => return Some(Self::Elixir),
192 "Package.swift" => return Some(Self::Swift),
193 "build.zig" => return Some(Self::Zig),
194 "project.godot" => return Some(Self::Godot),
195 "Assembly-CSharp.csproj" => return Some(Self::Unity),
196 "go.mod" => return Some(Self::Go),
197 "Gemfile" => return Some(Self::Ruby),
198 "Dockerfile" => return Some(Self::Docker),
199 "WORKSPACE" | "WORKSPACE.bazel" => return Some(Self::Bazel),
200 "BUILD" | "BUILD.bazel" => return Some(Self::Bazel),
201 _ => {}
202 }
203
204 if file_name_str.ends_with(".uproject") {
206 return Some(Self::Unreal);
207 }
208 if file_name_str.ends_with(".csproj") || file_name_str.ends_with(".fsproj") {
209 if Self::has_file(path, "project.godot") {
211 return Some(Self::Godot);
212 } else if Self::has_file(path, "Assembly-CSharp.csproj") {
213 return Some(Self::Unity);
214 } else {
215 return Some(Self::DotNet);
216 }
217 }
218 if file_name_str.ends_with(".ipynb") {
219 return Some(Self::Jupyter);
220 }
221 if file_name_str.ends_with(".tf") {
222 return Some(Self::Terraform);
223 }
224 if file_name_str.ends_with(".py") {
225 if Self::has_any_artifact(path, Self::Python.artifact_directories()) {
227 return Some(Self::Python);
228 }
229 }
230 }
231
232 None
233 }
234
235 fn has_file(dir: &Path, file_name: &str) -> bool {
237 dir.join(file_name).exists()
238 }
239
240 fn has_any_artifact(dir: &Path, artifacts: &[&str]) -> bool {
242 artifacts.iter().any(|artifact| {
243 let artifact_path = dir.join(artifact);
244 artifact_path.exists()
245 })
246 }
247}
248
249#[derive(Debug, Clone)]
255pub struct Project {
256 pub project_type: ProjectType,
258 pub path: PathBuf,
260}
261
262impl Project {
263 pub fn new(project_type: ProjectType, path: PathBuf) -> Self {
265 Self { project_type, path }
266 }
267
268 pub fn display_name(&self) -> String {
270 self.path
271 .file_name()
272 .and_then(|n| n.to_str())
273 .unwrap_or("Unknown")
274 .to_string()
275 }
276
277 pub fn calculate_artifact_size(&self, options: &ScanOptions) -> u64 {
279 let mut total_size = 0u64;
280
281 for artifact_dir in self.project_type.artifact_directories() {
282 let artifact_path = self.path.join(artifact_dir);
283 if artifact_path.exists() {
284 total_size += calculate_directory_size(&artifact_path, options);
285 }
286 }
287
288 total_size
289 }
290
291 pub fn last_modified(&self, options: &ScanOptions) -> Result<SystemTime, std::io::Error> {
293 let metadata = fs::metadata(&self.path)?;
294 let mut most_recent = metadata.modified()?;
295
296 let walker = walkdir::WalkDir::new(&self.path)
298 .follow_links(options.follow_symlinks)
299 .same_file_system(options.same_filesystem);
300
301 for entry in walker.into_iter().filter_map(|e| e.ok()) {
302 if let Ok(metadata) = entry.metadata() {
303 if let Ok(modified) = metadata.modified() {
304 if modified > most_recent {
305 most_recent = modified;
306 }
307 }
308 }
309 }
310
311 Ok(most_recent)
312 }
313
314 pub fn clean(&self) -> Result<u64, CleanError> {
316 let mut total_deleted = 0u64;
317 let mut errors = Vec::new();
318
319 for artifact_dir in self.project_type.artifact_directories() {
320 let artifact_path = self.path.join(artifact_dir);
321
322 if !artifact_path.exists() {
323 continue;
324 }
325
326 let size = calculate_directory_size(&artifact_path, &ScanOptions::default());
328
329 match fs::remove_dir_all(&artifact_path) {
331 Ok(_) => {
332 total_deleted += size;
333 }
334 Err(e) => {
335 errors.push((artifact_path.clone(), e));
336 }
337 }
338 }
339
340 if errors.is_empty() {
341 Ok(total_deleted)
342 } else {
343 Err(CleanError::PartialFailure {
344 deleted: total_deleted,
345 errors,
346 })
347 }
348 }
349}
350
351#[derive(Debug, Clone)]
357pub struct ScanOptions {
358 pub follow_symlinks: bool,
360 pub same_filesystem: bool,
362 pub min_age_seconds: u64,
364}
365
366impl Default for ScanOptions {
367 fn default() -> Self {
368 Self {
369 follow_symlinks: false,
370 same_filesystem: true,
371 min_age_seconds: 0,
372 }
373 }
374}
375
376pub fn scan_directory<P: AsRef<Path>>(
382 path: P,
383 options: &ScanOptions,
384) -> impl Iterator<Item = Result<Project, ScanError>> {
385 let path = path.as_ref().to_path_buf();
386 let options = options.clone();
387
388 let walker = walkdir::WalkDir::new(&path)
390 .follow_links(options.follow_symlinks)
391 .same_file_system(options.same_filesystem)
392 .into_iter();
393
394 walker.filter_map(move |entry| {
396 let entry = match entry {
397 Ok(e) => e,
398 Err(e) => return Some(Err(ScanError::WalkError(e))),
399 };
400
401 if !entry.file_type().is_dir() {
403 return None;
404 }
405
406 if entry.file_name().to_string_lossy().starts_with('.') {
408 return None;
409 }
410
411 let dir_path = entry.path();
412
413 if let Some(project_type) = ProjectType::detect_from_directory(dir_path) {
415 let project = Project::new(project_type, dir_path.to_path_buf());
416
417 if options.min_age_seconds > 0 {
419 if let Ok(last_modified) = project.last_modified(&options) {
420 if let Ok(elapsed) = last_modified.elapsed() {
421 if elapsed.as_secs() < options.min_age_seconds {
422 return None; }
424 }
425 }
426 }
427
428 return Some(Ok(project));
429 }
430
431 None
432 })
433}
434
435pub fn calculate_directory_size<P: AsRef<Path>>(path: P, options: &ScanOptions) -> u64 {
437 let walker = walkdir::WalkDir::new(path.as_ref())
438 .follow_links(options.follow_symlinks)
439 .same_file_system(options.same_filesystem);
440
441 walker
442 .into_iter()
443 .filter_map(|e| e.ok())
444 .filter(|e| e.file_type().is_file())
445 .filter_map(|e| e.metadata().ok())
446 .map(|m| m.len())
447 .sum()
448}
449
450pub fn format_size(bytes: u64) -> String {
456 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
457 const THRESHOLD: f64 = 1024.0;
458
459 if bytes == 0 {
460 return "0 B".to_string();
461 }
462
463 let bytes_f64 = bytes as f64;
464 let unit_index = (bytes_f64.log(THRESHOLD).floor() as usize).min(UNITS.len() - 1);
465 let size = bytes_f64 / THRESHOLD.powi(unit_index as i32);
466
467 format!("{:.1} {}", size, UNITS[unit_index])
468}
469
470pub fn format_elapsed_time(seconds: u64) -> String {
472 const MINUTE: u64 = 60;
473 const HOUR: u64 = MINUTE * 60;
474 const DAY: u64 = HOUR * 24;
475 const WEEK: u64 = DAY * 7;
476 const MONTH: u64 = DAY * 30;
477 const YEAR: u64 = DAY * 365;
478
479 let (value, unit) = match seconds {
480 s if s < MINUTE => (s, "second"),
481 s if s < HOUR => (s / MINUTE, "minute"),
482 s if s < DAY => (s / HOUR, "hour"),
483 s if s < WEEK => (s / DAY, "day"),
484 s if s < MONTH => (s / WEEK, "week"),
485 s if s < YEAR => (s / MONTH, "month"),
486 s => (s / YEAR, "year"),
487 };
488
489 let plural = if value == 1 { "" } else { "s" };
490 format!("{} {}{} ago", value, unit, plural)
491}
492
493#[derive(Debug)]
499pub enum ScanError {
500 WalkError(walkdir::Error),
502 IoError(std::io::Error),
504}
505
506impl fmt::Display for ScanError {
507 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
508 match self {
509 Self::WalkError(e) => write!(f, "Walk error: {}", e),
510 Self::IoError(e) => write!(f, "IO error: {}", e),
511 }
512 }
513}
514
515impl Error for ScanError {}
516
517impl From<walkdir::Error> for ScanError {
518 fn from(e: walkdir::Error) -> Self {
519 Self::WalkError(e)
520 }
521}
522
523impl From<std::io::Error> for ScanError {
524 fn from(e: std::io::Error) -> Self {
525 Self::IoError(e)
526 }
527}
528
529#[derive(Debug)]
531pub enum CleanError {
532 IoError(std::io::Error),
534 PartialFailure {
536 deleted: u64,
537 errors: Vec<(PathBuf, std::io::Error)>,
538 },
539}
540
541impl fmt::Display for CleanError {
542 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
543 match self {
544 Self::IoError(e) => write!(f, "Clean error: {}", e),
545 Self::PartialFailure { deleted, errors } => {
546 write!(
547 f,
548 "Partially cleaned ({} bytes), {} errors occurred",
549 deleted,
550 errors.len()
551 )
552 }
553 }
554 }
555}
556
557impl Error for CleanError {}
558
559impl From<std::io::Error> for CleanError {
560 fn from(e: std::io::Error) -> Self {
561 Self::IoError(e)
562 }
563}
564
565#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn test_format_size() {
575 assert_eq!(format_size(0), "0 B");
576 assert_eq!(format_size(512), "512.0 B");
577 assert_eq!(format_size(1024), "1.0 KB");
578 assert_eq!(format_size(1536), "1.5 KB");
579 assert_eq!(format_size(1_048_576), "1.0 MB");
580 assert_eq!(format_size(1_073_741_824), "1.0 GB");
581 }
582
583 #[test]
584 fn test_format_elapsed_time() {
585 assert_eq!(format_elapsed_time(0), "0 seconds ago");
586 assert_eq!(format_elapsed_time(1), "1 second ago");
587 assert_eq!(format_elapsed_time(59), "59 seconds ago");
588 assert_eq!(format_elapsed_time(60), "1 minute ago");
589 assert_eq!(format_elapsed_time(3600), "1 hour ago");
590 assert_eq!(format_elapsed_time(86400), "1 day ago");
591 }
592
593 #[test]
594 fn test_project_type_names() {
595 assert_eq!(ProjectType::Rust.name(), "Rust");
596 assert_eq!(ProjectType::Node.name(), "Node.js");
597 assert_eq!(ProjectType::Python.name(), "Python");
598 assert_eq!(ProjectType::Go.name(), "Go");
599 assert_eq!(ProjectType::Ruby.name(), "Ruby");
600 assert_eq!(ProjectType::Terraform.name(), "Terraform");
601 assert_eq!(ProjectType::Docker.name(), "Docker");
602 assert_eq!(ProjectType::Bazel.name(), "Bazel");
603 }
604}