devdust_core/
lib.rs

1//! Dev Dust Core Library
2//!
3//! This library provides functionality to detect various types of development projects
4//! and clean their build artifacts to reclaim disk space.
5//!
6//! Supported project types:
7//! - Rust (Cargo)
8//! - Node.js/JavaScript
9//! - Python
10//! - .NET (C#/F#)
11//! - Java (Maven, Gradle)
12//! - Unity
13//! - Unreal Engine
14//! - And many more...
15
16use std::{
17    error::Error,
18    fmt, fs,
19    path::{Path, PathBuf},
20    time::SystemTime,
21};
22
23// ============================================================================
24// Project Type Definitions
25// ============================================================================
26
27/// Represents different types of development projects we can detect
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ProjectType {
30    /// Rust projects (Cargo.toml)
31    Rust,
32    /// Node.js/JavaScript projects (package.json)
33    Node,
34    /// Python projects (.py files with common artifacts)
35    Python,
36    /// .NET projects (.csproj, .fsproj)
37    DotNet,
38    /// Unity game engine projects
39    Unity,
40    /// Unreal Engine projects (.uproject)
41    Unreal,
42    /// Java Maven projects (pom.xml)
43    Maven,
44    /// Java/Kotlin Gradle projects (build.gradle)
45    Gradle,
46    /// CMake projects (CMakeLists.txt)
47    CMake,
48    /// Haskell Stack projects (stack.yaml)
49    HaskellStack,
50    /// Scala SBT projects (build.sbt)
51    ScalaSBT,
52    /// PHP Composer projects (composer.json)
53    Composer,
54    /// Dart/Flutter projects (pubspec.yaml)
55    Dart,
56    /// Elixir projects (mix.exs)
57    Elixir,
58    /// Swift projects (Package.swift)
59    Swift,
60    /// Zig projects (build.zig)
61    Zig,
62    /// Godot 4.x projects (project.godot)
63    Godot,
64    /// Jupyter notebooks (.ipynb)
65    Jupyter,
66    /// Go projects (go.mod)
67    Go,
68    /// Ruby projects (Gemfile)
69    Ruby,
70    /// Terraform projects (*.tf files)
71    Terraform,
72    /// Docker projects (Dockerfile)
73    Docker,
74    /// Bazel projects (WORKSPACE, BUILD)
75    Bazel,
76}
77
78impl ProjectType {
79    /// Returns the human-readable name of the project type
80    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    /// Returns the directories that contain build artifacts for this project type
109    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    /// Detects project type from a directory by checking for marker files
171    pub fn detect_from_directory(path: &Path) -> Option<Self> {
172        // Read directory entries
173        let entries: Vec<_> = fs::read_dir(path).ok()?.filter_map(|e| e.ok()).collect();
174
175        // Check for specific marker files
176        for entry in &entries {
177            let file_name = entry.file_name();
178            let file_name_str = file_name.to_string_lossy();
179
180            // Check exact file names
181            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            // Check file extensions
205            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                // Distinguish between Unity, Godot, and regular .NET
210                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                // Check if there are Python artifacts
226                if Self::has_any_artifact(path, Self::Python.artifact_directories()) {
227                    return Some(Self::Python);
228                }
229            }
230        }
231
232        None
233    }
234
235    /// Helper: Check if a directory contains a specific file
236    fn has_file(dir: &Path, file_name: &str) -> bool {
237        dir.join(file_name).exists()
238    }
239
240    /// Helper: Check if a directory contains any of the specified artifacts
241    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// ============================================================================
250// Project Structure
251// ============================================================================
252
253/// Represents a detected development project
254#[derive(Debug, Clone)]
255pub struct Project {
256    /// The type of project detected
257    pub project_type: ProjectType,
258    /// The root path of the project
259    pub path: PathBuf,
260}
261
262impl Project {
263    /// Creates a new Project instance
264    pub fn new(project_type: ProjectType, path: PathBuf) -> Self {
265        Self { project_type, path }
266    }
267
268    /// Returns the display name of the project (usually the directory name)
269    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    /// Calculates the total size of artifact directories in bytes
278    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    /// Gets the last modified time of the project
292    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        // Walk through the project to find the most recent modification
297        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    /// Cleans (deletes) all artifact directories for this project
315    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            // Calculate size before deletion
327            let size = calculate_directory_size(&artifact_path, &ScanOptions::default());
328
329            // Attempt to delete the directory
330            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// ============================================================================
352// Scanning Configuration
353// ============================================================================
354
355/// Options for scanning directories
356#[derive(Debug, Clone)]
357pub struct ScanOptions {
358    /// Whether to follow symbolic links
359    pub follow_symlinks: bool,
360    /// Whether to stay on the same filesystem
361    pub same_filesystem: bool,
362    /// Minimum age in seconds for projects to be included
363    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
376// ============================================================================
377// Scanning Functions
378// ============================================================================
379
380/// Scans a directory recursively to find development projects
381pub 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    // Create a walkdir iterator with the specified options
389    let walker = walkdir::WalkDir::new(&path)
390        .follow_links(options.follow_symlinks)
391        .same_file_system(options.same_filesystem)
392        .into_iter();
393
394    // Filter and map entries to projects
395    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        // Only process directories
402        if !entry.file_type().is_dir() {
403            return None;
404        }
405
406        // Skip hidden directories (starting with .)
407        if entry.file_name().to_string_lossy().starts_with('.') {
408            return None;
409        }
410
411        let dir_path = entry.path();
412
413        // Try to detect project type
414        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            // Check age filter if specified
418            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; // Too recent, skip
423                        }
424                    }
425                }
426            }
427
428            return Some(Ok(project));
429        }
430
431        None
432    })
433}
434
435/// Calculates the total size of a directory in bytes
436pub 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
450// ============================================================================
451// Utility Functions
452// ============================================================================
453
454/// Formats a byte size into a human-readable string (e.g., "1.5 GB")
455pub 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
470/// Formats elapsed time into a human-readable string (e.g., "2 days ago")
471pub 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// ============================================================================
494// Error Types
495// ============================================================================
496
497/// Errors that can occur during scanning
498#[derive(Debug)]
499pub enum ScanError {
500    /// Error from walkdir
501    WalkError(walkdir::Error),
502    /// IO error
503    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/// Errors that can occur during cleaning
530#[derive(Debug)]
531pub enum CleanError {
532    /// Complete failure to clean
533    IoError(std::io::Error),
534    /// Some directories were cleaned, but others failed
535    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// ============================================================================
566// Tests
567// ============================================================================
568
569#[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}