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}
67
68impl ProjectType {
69    /// Returns the human-readable name of the project type
70    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    /// Returns the directories that contain build artifacts for this project type
94    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    /// Detects project type from a directory by checking for marker files
151    pub fn detect_from_directory(path: &Path) -> Option<Self> {
152        // Read directory entries
153        let entries: Vec<_> = fs::read_dir(path).ok()?.filter_map(|e| e.ok()).collect();
154
155        // Check for specific marker files
156        for entry in &entries {
157            let file_name = entry.file_name();
158            let file_name_str = file_name.to_string_lossy();
159
160            // Check exact file names
161            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            // Check file extensions
180            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                // Distinguish between Unity, Godot, and regular .NET
185                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                // Check if there are Python artifacts
198                if Self::has_any_artifact(path, Self::Python.artifact_directories()) {
199                    return Some(Self::Python);
200                }
201            }
202        }
203
204        None
205    }
206
207    /// Helper: Check if a directory contains a specific file
208    fn has_file(dir: &Path, file_name: &str) -> bool {
209        dir.join(file_name).exists()
210    }
211
212    /// Helper: Check if a directory contains any of the specified artifacts
213    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// ============================================================================
222// Project Structure
223// ============================================================================
224
225/// Represents a detected development project
226#[derive(Debug, Clone)]
227pub struct Project {
228    /// The type of project detected
229    pub project_type: ProjectType,
230    /// The root path of the project
231    pub path: PathBuf,
232}
233
234impl Project {
235    /// Creates a new Project instance
236    pub fn new(project_type: ProjectType, path: PathBuf) -> Self {
237        Self { project_type, path }
238    }
239
240    /// Returns the display name of the project (usually the directory name)
241    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    /// Calculates the total size of artifact directories in bytes
250    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    /// Gets the last modified time of the project
264    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        // Walk through the project to find the most recent modification
269        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    /// Cleans (deletes) all artifact directories for this project
287    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            // Calculate size before deletion
299            let size = calculate_directory_size(&artifact_path, &ScanOptions::default());
300
301            // Attempt to delete the directory
302            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// ============================================================================
324// Scanning Configuration
325// ============================================================================
326
327/// Options for scanning directories
328#[derive(Debug, Clone)]
329pub struct ScanOptions {
330    /// Whether to follow symbolic links
331    pub follow_symlinks: bool,
332    /// Whether to stay on the same filesystem
333    pub same_filesystem: bool,
334    /// Minimum age in seconds for projects to be included
335    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
348// ============================================================================
349// Scanning Functions
350// ============================================================================
351
352/// Scans a directory recursively to find development projects
353pub 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    // Create a walkdir iterator with the specified options
361    let walker = walkdir::WalkDir::new(&path)
362        .follow_links(options.follow_symlinks)
363        .same_file_system(options.same_filesystem)
364        .into_iter();
365
366    // Filter and map entries to projects
367    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        // Only process directories
374        if !entry.file_type().is_dir() {
375            return None;
376        }
377
378        // Skip hidden directories (starting with .)
379        if entry.file_name().to_string_lossy().starts_with('.') {
380            return None;
381        }
382
383        let dir_path = entry.path();
384
385        // Try to detect project type
386        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            // Check age filter if specified
390            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; // Too recent, skip
395                        }
396                    }
397                }
398            }
399
400            return Some(Ok(project));
401        }
402
403        None
404    })
405}
406
407/// Calculates the total size of a directory in bytes
408pub 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
422// ============================================================================
423// Utility Functions
424// ============================================================================
425
426/// Formats a byte size into a human-readable string (e.g., "1.5 GB")
427pub 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
442/// Formats elapsed time into a human-readable string (e.g., "2 days ago")
443pub 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// ============================================================================
466// Error Types
467// ============================================================================
468
469/// Errors that can occur during scanning
470#[derive(Debug)]
471pub enum ScanError {
472    /// Error from walkdir
473    WalkError(walkdir::Error),
474    /// IO error
475    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/// Errors that can occur during cleaning
502#[derive(Debug)]
503pub enum CleanError {
504    /// Complete failure to clean
505    IoError(std::io::Error),
506    /// Some directories were cleaned, but others failed
507    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// ============================================================================
538// Tests
539// ============================================================================
540
541#[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}