Skip to main content

null_e/core/
artifact.rs

1//! Artifact types and metadata
2//!
3//! Artifacts are cleanable items within a project, such as:
4//! - Dependencies (node_modules, vendor)
5//! - Build outputs (target, dist, build)
6//! - Caches (__pycache__, .cache)
7//! - Virtual environments (.venv)
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::time::Duration;
13
14/// A cleanable artifact within a project
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Artifact {
17    /// Full path to the artifact
18    pub path: PathBuf,
19    /// Type of artifact
20    pub kind: ArtifactKind,
21    /// Size in bytes
22    pub size: u64,
23    /// Number of files contained
24    pub file_count: u64,
25    /// Age since last modification
26    pub age: Option<Duration>,
27    /// Additional metadata
28    pub metadata: ArtifactMetadata,
29}
30
31impl Artifact {
32    /// Create a new artifact
33    pub fn new(path: PathBuf, kind: ArtifactKind) -> Self {
34        Self {
35            path,
36            kind,
37            size: 0,
38            file_count: 0,
39            age: None,
40            metadata: ArtifactMetadata::default(),
41        }
42    }
43
44    /// Get the artifact name (directory/file name)
45    pub fn name(&self) -> &str {
46        self.path
47            .file_name()
48            .and_then(|n| n.to_str())
49            .unwrap_or("unknown")
50    }
51
52    /// Check if this artifact can be safely deleted based on its kind
53    pub fn is_safe_to_clean(&self) -> bool {
54        match self.kind.default_safety() {
55            ArtifactSafety::AlwaysSafe => true,
56            ArtifactSafety::SafeIfGitClean => true, // Caller should check git
57            ArtifactSafety::SafeWithLockfile => self.metadata.lockfile.is_some(),
58            ArtifactSafety::RequiresConfirmation => false,
59            ArtifactSafety::NeverAuto => false,
60        }
61    }
62
63    /// Get a human-readable size string
64    pub fn size_display(&self) -> String {
65        humansize::format_size(self.size, humansize::BINARY)
66    }
67}
68
69impl std::fmt::Display for Artifact {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(
72            f,
73            "{} ({}) - {}",
74            self.name(),
75            self.kind.description(),
76            self.size_display()
77        )
78    }
79}
80
81/// Classification of artifact types
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
83#[non_exhaustive]
84pub enum ArtifactKind {
85    /// Package dependencies (node_modules, vendor, etc.)
86    Dependencies,
87    /// Build outputs (target, dist, build, etc.)
88    BuildOutput,
89    /// Cache directories (.cache, __pycache__, etc.)
90    Cache,
91    /// Virtual environments (.venv, venv, etc.)
92    VirtualEnv,
93    /// IDE/editor artifacts (.idea, .vscode local, etc.)
94    IdeArtifacts,
95    /// Test outputs (coverage, .nyc_output, etc.)
96    TestOutput,
97    /// Log files
98    Logs,
99    /// Temporary files
100    Temporary,
101    /// Lock files (generally should NOT delete)
102    LockFile,
103    /// Docker-related (dangling images, build cache)
104    Docker,
105    /// Package manager cache (npm cache, pip cache)
106    PackageManagerCache,
107    /// Compiled bytecode (.pyc files, .class files)
108    Bytecode,
109    /// Documentation builds
110    DocsBuild,
111    /// Custom plugin-defined artifact
112    Custom(u32),
113}
114
115impl ArtifactKind {
116    /// Default safety level for this artifact type
117    pub fn default_safety(&self) -> ArtifactSafety {
118        match self {
119            Self::Cache | Self::Logs | Self::Temporary | Self::Bytecode => ArtifactSafety::AlwaysSafe,
120            Self::BuildOutput | Self::TestOutput | Self::DocsBuild => ArtifactSafety::SafeIfGitClean,
121            Self::Dependencies | Self::PackageManagerCache => ArtifactSafety::SafeWithLockfile,
122            Self::VirtualEnv => ArtifactSafety::RequiresConfirmation,
123            Self::IdeArtifacts => ArtifactSafety::RequiresConfirmation,
124            Self::Docker => ArtifactSafety::RequiresConfirmation,
125            Self::LockFile => ArtifactSafety::NeverAuto,
126            Self::Custom(_) => ArtifactSafety::RequiresConfirmation,
127        }
128    }
129
130    /// Human-readable description
131    pub fn description(&self) -> &'static str {
132        match self {
133            Self::Dependencies => "dependencies",
134            Self::BuildOutput => "build output",
135            Self::Cache => "cache",
136            Self::VirtualEnv => "virtual environment",
137            Self::IdeArtifacts => "IDE artifacts",
138            Self::TestOutput => "test output",
139            Self::Logs => "logs",
140            Self::Temporary => "temporary files",
141            Self::LockFile => "lock file",
142            Self::Docker => "Docker artifacts",
143            Self::PackageManagerCache => "package cache",
144            Self::Bytecode => "bytecode",
145            Self::DocsBuild => "documentation build",
146            Self::Custom(_) => "custom",
147        }
148    }
149
150    /// Get the icon/emoji for this artifact kind
151    pub fn icon(&self) -> &'static str {
152        match self {
153            Self::Dependencies => "๐Ÿ“ฆ",
154            Self::BuildOutput => "๐Ÿ”จ",
155            Self::Cache => "๐Ÿ’พ",
156            Self::VirtualEnv => "๐Ÿ",
157            Self::IdeArtifacts => "๐Ÿ’ป",
158            Self::TestOutput => "๐Ÿงช",
159            Self::Logs => "๐Ÿ“",
160            Self::Temporary => "๐Ÿ—‘๏ธ",
161            Self::LockFile => "๐Ÿ”’",
162            Self::Docker => "๐Ÿณ",
163            Self::PackageManagerCache => "๐Ÿ“ฅ",
164            Self::Bytecode => "โš™๏ธ",
165            Self::DocsBuild => "๐Ÿ“š",
166            Self::Custom(_) => "๐Ÿ“",
167        }
168    }
169
170    /// Get all standard artifact kinds
171    pub fn all() -> &'static [ArtifactKind] {
172        &[
173            Self::Dependencies,
174            Self::BuildOutput,
175            Self::Cache,
176            Self::VirtualEnv,
177            Self::IdeArtifacts,
178            Self::TestOutput,
179            Self::Logs,
180            Self::Temporary,
181            Self::Docker,
182            Self::PackageManagerCache,
183            Self::Bytecode,
184            Self::DocsBuild,
185        ]
186    }
187}
188
189/// Safety classification for artifacts
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum ArtifactSafety {
192    /// Always safe to delete (cache, temp, logs)
193    AlwaysSafe,
194    /// Safe if git working tree is clean
195    SafeIfGitClean,
196    /// Safe if lockfile exists (dependencies can be reinstalled)
197    SafeWithLockfile,
198    /// Requires explicit user confirmation
199    RequiresConfirmation,
200    /// Should never be auto-deleted
201    NeverAuto,
202}
203
204/// Additional metadata about an artifact
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct ArtifactMetadata {
207    /// Whether this can be restored (reinstalled/rebuilt)
208    pub restorable: bool,
209    /// Command to restore (e.g., "npm install", "cargo build")
210    pub restore_command: Option<String>,
211    /// Associated lockfile that enables restoration
212    pub lockfile: Option<PathBuf>,
213    /// Estimated restoration time in seconds
214    pub restore_time_estimate: Option<u32>,
215    /// Custom properties from plugins
216    #[serde(default)]
217    pub extra: HashMap<String, String>,
218}
219
220impl ArtifactMetadata {
221    /// Create metadata for a restorable artifact
222    pub fn restorable(command: impl Into<String>) -> Self {
223        Self {
224            restorable: true,
225            restore_command: Some(command.into()),
226            ..Default::default()
227        }
228    }
229
230    /// Set the lockfile
231    pub fn with_lockfile(mut self, lockfile: PathBuf) -> Self {
232        self.lockfile = Some(lockfile);
233        self
234    }
235
236    /// Set restoration time estimate
237    pub fn with_restore_time(mut self, seconds: u32) -> Self {
238        self.restore_time_estimate = Some(seconds);
239        self
240    }
241}
242
243/// Statistics about artifacts found during scan
244#[derive(Debug, Clone, Default)]
245pub struct ArtifactStats {
246    /// Total size of all artifacts
247    pub total_size: u64,
248    /// Total number of files
249    pub total_files: u64,
250    /// Total number of artifacts
251    pub total_artifacts: usize,
252    /// Stats by artifact kind
253    pub by_kind: HashMap<ArtifactKind, KindStats>,
254}
255
256impl ArtifactStats {
257    /// Add an artifact to the stats
258    pub fn add(&mut self, artifact: &Artifact) {
259        self.total_size += artifact.size;
260        self.total_files += artifact.file_count;
261        self.total_artifacts += 1;
262
263        let entry = self.by_kind.entry(artifact.kind).or_default();
264        entry.count += 1;
265        entry.total_size += artifact.size;
266        entry.file_count += artifact.file_count;
267    }
268
269    /// Get the largest artifact kind by size
270    pub fn largest_kind(&self) -> Option<(ArtifactKind, u64)> {
271        self.by_kind
272            .iter()
273            .max_by_key(|(_, stats)| stats.total_size)
274            .map(|(kind, stats)| (*kind, stats.total_size))
275    }
276}
277
278/// Statistics for a specific artifact kind
279#[derive(Debug, Clone, Default)]
280pub struct KindStats {
281    /// Number of artifacts of this kind
282    pub count: usize,
283    /// Total size
284    pub total_size: u64,
285    /// Total file count
286    pub file_count: u64,
287}
288
289/// Result of a cleaning operation for a single artifact
290#[derive(Debug, Clone)]
291pub struct CleanResult {
292    /// The artifact that was (attempted to be) cleaned
293    pub artifact: Artifact,
294    /// Whether the clean was successful
295    pub success: bool,
296    /// Error message if failed
297    pub error: Option<String>,
298    /// Actual bytes freed (may differ from artifact.size)
299    pub bytes_freed: u64,
300    /// Whether it was moved to trash (vs permanent delete)
301    pub trashed: bool,
302}
303
304impl CleanResult {
305    /// Create a successful clean result
306    pub fn success(artifact: Artifact, trashed: bool) -> Self {
307        let bytes_freed = artifact.size;
308        Self {
309            artifact,
310            success: true,
311            error: None,
312            bytes_freed,
313            trashed,
314        }
315    }
316
317    /// Create a failed clean result
318    pub fn failure(artifact: Artifact, error: impl Into<String>) -> Self {
319        Self {
320            artifact,
321            success: false,
322            error: Some(error.into()),
323            bytes_freed: 0,
324            trashed: false,
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_artifact_kind_safety() {
335        assert_eq!(
336            ArtifactKind::Cache.default_safety(),
337            ArtifactSafety::AlwaysSafe
338        );
339        assert_eq!(
340            ArtifactKind::Dependencies.default_safety(),
341            ArtifactSafety::SafeWithLockfile
342        );
343        assert_eq!(
344            ArtifactKind::LockFile.default_safety(),
345            ArtifactSafety::NeverAuto
346        );
347    }
348
349    #[test]
350    fn test_artifact_stats() {
351        let mut stats = ArtifactStats::default();
352
353        let artifact1 = Artifact {
354            path: PathBuf::from("/test/node_modules"),
355            kind: ArtifactKind::Dependencies,
356            size: 1000,
357            file_count: 100,
358            age: None,
359            metadata: ArtifactMetadata::default(),
360        };
361
362        let artifact2 = Artifact {
363            path: PathBuf::from("/test/.cache"),
364            kind: ArtifactKind::Cache,
365            size: 500,
366            file_count: 50,
367            age: None,
368            metadata: ArtifactMetadata::default(),
369        };
370
371        stats.add(&artifact1);
372        stats.add(&artifact2);
373
374        assert_eq!(stats.total_size, 1500);
375        assert_eq!(stats.total_files, 150);
376        assert_eq!(stats.total_artifacts, 2);
377    }
378}