1use serde::{Deserialize, Serialize};
7use std::collections::hash_map::DefaultHasher;
8use std::hash::{Hash, Hasher};
9use std::path::{Path, PathBuf};
10use std::time::SystemTime;
11
12use super::Artifact;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct ProjectId(pub u64);
17
18impl ProjectId {
19 pub fn from_path(path: &Path) -> Self {
21 let mut hasher = DefaultHasher::new();
22 path.hash(&mut hasher);
23 Self(hasher.finish())
24 }
25}
26
27impl std::fmt::Display for ProjectId {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 write!(f, "{:016x}", self.0)
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[non_exhaustive]
36pub enum ProjectKind {
37 NodeNpm,
41 NodeYarn,
42 NodePnpm,
43 NodeBun,
44 Deno,
45
46 Rust,
50 Go,
51 Cpp,
52 C,
53 Zig,
54
55 JavaMaven,
59 JavaGradle,
60 Kotlin,
61 Scala,
62 Clojure,
63
64 DotNet,
68 FSharp,
69
70 PythonPip,
74 PythonPoetry,
75 PythonPipenv,
76 PythonConda,
77 PythonUv,
78
79 RubyBundler,
83 RubyRails,
84
85 PhpComposer,
89 PhpLaravel,
90
91 SwiftSpm,
95 SwiftXcode,
96 Flutter,
97 ReactNative,
98 Android,
99
100 Elixir,
104 Haskell,
105 OCaml,
106 Julia,
107 R,
108 Lua,
109 Perl,
110
111 Terraform,
115 Pulumi,
116
117 Docker,
121
122 Custom(u32),
124}
125
126impl ProjectKind {
127 pub fn display_name(&self) -> &'static str {
129 match self {
130 Self::NodeNpm => "Node.js (npm)",
131 Self::NodeYarn => "Node.js (Yarn)",
132 Self::NodePnpm => "Node.js (pnpm)",
133 Self::NodeBun => "Bun",
134 Self::Deno => "Deno",
135 Self::Rust => "Rust (Cargo)",
136 Self::Go => "Go",
137 Self::Cpp => "C++",
138 Self::C => "C",
139 Self::Zig => "Zig",
140 Self::JavaMaven => "Java (Maven)",
141 Self::JavaGradle => "Java (Gradle)",
142 Self::Kotlin => "Kotlin",
143 Self::Scala => "Scala",
144 Self::Clojure => "Clojure",
145 Self::DotNet => ".NET",
146 Self::FSharp => "F#",
147 Self::PythonPip => "Python (pip)",
148 Self::PythonPoetry => "Python (Poetry)",
149 Self::PythonPipenv => "Python (Pipenv)",
150 Self::PythonConda => "Python (Conda)",
151 Self::PythonUv => "Python (uv)",
152 Self::RubyBundler => "Ruby (Bundler)",
153 Self::RubyRails => "Ruby on Rails",
154 Self::PhpComposer => "PHP (Composer)",
155 Self::PhpLaravel => "PHP (Laravel)",
156 Self::SwiftSpm => "Swift (SPM)",
157 Self::SwiftXcode => "Swift (Xcode)",
158 Self::Flutter => "Flutter",
159 Self::ReactNative => "React Native",
160 Self::Android => "Android",
161 Self::Elixir => "Elixir",
162 Self::Haskell => "Haskell",
163 Self::OCaml => "OCaml",
164 Self::Julia => "Julia",
165 Self::R => "R",
166 Self::Lua => "Lua",
167 Self::Perl => "Perl",
168 Self::Terraform => "Terraform",
169 Self::Pulumi => "Pulumi",
170 Self::Docker => "Docker",
171 Self::Custom(_) => "Custom",
172 }
173 }
174
175 pub fn icon(&self) -> &'static str {
177 match self {
178 Self::NodeNpm | Self::NodeYarn | Self::NodePnpm | Self::NodeBun | Self::Deno => "📦",
179 Self::Rust => "🦀",
180 Self::Go => "🐹",
181 Self::Cpp | Self::C => "⚙️",
182 Self::Zig => "⚡",
183 Self::JavaMaven | Self::JavaGradle | Self::Kotlin | Self::Scala | Self::Clojure => "☕",
184 Self::DotNet | Self::FSharp => "🔷",
185 Self::PythonPip | Self::PythonPoetry | Self::PythonPipenv | Self::PythonConda | Self::PythonUv => "🐍",
186 Self::RubyBundler | Self::RubyRails => "💎",
187 Self::PhpComposer | Self::PhpLaravel => "🐘",
188 Self::SwiftSpm | Self::SwiftXcode => "🍎",
189 Self::Flutter => "🦋",
190 Self::ReactNative => "⚛️",
191 Self::Android => "🤖",
192 Self::Elixir => "💧",
193 Self::Haskell => "λ",
194 Self::OCaml => "🐫",
195 Self::Julia => "📊",
196 Self::R => "📈",
197 Self::Lua => "🌙",
198 Self::Perl => "🐪",
199 Self::Terraform | Self::Pulumi => "🏗️",
200 Self::Docker => "🐳",
201 Self::Custom(_) => "📁",
202 }
203 }
204
205 pub fn is_node(&self) -> bool {
207 matches!(
208 self,
209 Self::NodeNpm | Self::NodeYarn | Self::NodePnpm | Self::NodeBun | Self::Deno
210 )
211 }
212
213 pub fn is_rust(&self) -> bool {
215 matches!(self, Self::Rust)
216 }
217
218 pub fn is_python(&self) -> bool {
220 matches!(
221 self,
222 Self::PythonPip
223 | Self::PythonPoetry
224 | Self::PythonPipenv
225 | Self::PythonConda
226 | Self::PythonUv
227 )
228 }
229
230 pub fn is_java(&self) -> bool {
232 matches!(
233 self,
234 Self::JavaMaven | Self::JavaGradle | Self::Kotlin | Self::Scala | Self::Clojure
235 )
236 }
237
238 pub fn is_go(&self) -> bool {
240 matches!(self, Self::Go)
241 }
242
243 pub fn is_swift(&self) -> bool {
245 matches!(self, Self::SwiftSpm | Self::SwiftXcode)
246 }
247
248 pub fn is_dotnet(&self) -> bool {
250 matches!(self, Self::DotNet | Self::FSharp)
251 }
252}
253
254#[derive(Debug, Clone)]
256pub struct ProjectMarker {
257 pub indicator: MarkerKind,
259 pub kind: ProjectKind,
261 pub priority: u8,
263}
264
265#[derive(Debug, Clone)]
267pub enum MarkerKind {
268 File(&'static str),
270 Directory(&'static str),
272 Extension(&'static str),
274 AllOf(Vec<&'static str>),
276 AnyOf(Vec<&'static str>),
278}
279
280impl MarkerKind {
281 pub fn matches(&self, path: &Path) -> bool {
283 match self {
284 Self::File(name) => path.join(name).is_file(),
285 Self::Directory(name) => path.join(name).is_dir(),
286 Self::Extension(ext) => {
287 path.extension()
288 .map(|e| e.to_string_lossy().as_ref() == *ext)
289 .unwrap_or(false)
290 }
291 Self::AllOf(files) => files.iter().all(|f| path.join(f).exists()),
292 Self::AnyOf(files) => files.iter().any(|f| path.join(f).exists()),
293 }
294 }
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct GitStatus {
300 pub is_repo: bool,
302 pub has_uncommitted: bool,
304 pub has_untracked: bool,
306 pub has_stashed: bool,
308 pub branch: Option<String>,
310 pub remote: Option<String>,
312 pub last_commit: Option<SystemTime>,
314 pub dirty_paths: Vec<PathBuf>,
316}
317
318impl GitStatus {
319 pub fn is_clean(&self) -> bool {
321 self.is_repo && !self.has_uncommitted && !self.has_untracked
322 }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
327pub enum CleanSafety {
328 Safe,
330 Warning(CleanWarning),
332 Blocked(CleanBlock),
334}
335
336#[derive(Debug, Clone, PartialEq, Eq)]
338pub enum CleanWarning {
339 UncommittedChanges { paths: Vec<PathBuf> },
341 UntrackedFiles,
343 NotGitRepo,
345 RecentlyModified { age_days: u32 },
347 NoLockfile,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum CleanBlock {
354 LockFilePresent(PathBuf),
356 ProcessRunning { pid: u32, name: String },
358 UserProtected,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct Project {
365 pub id: ProjectId,
367 pub kind: ProjectKind,
369 pub root: PathBuf,
371 pub name: String,
373 #[serde(skip)]
375 pub last_modified: Option<SystemTime>,
376 #[serde(skip)]
378 pub git_status: Option<GitStatus>,
379 pub artifacts: Vec<Artifact>,
381 pub total_size: u64,
383 pub cleanable_size: u64,
385}
386
387impl Project {
388 pub fn new(kind: ProjectKind, root: PathBuf) -> Self {
390 let id = ProjectId::from_path(&root);
391 let name = root
392 .file_name()
393 .map(|n| n.to_string_lossy().into_owned())
394 .unwrap_or_else(|| "unknown".into());
395
396 Self {
397 id,
398 kind,
399 root,
400 name,
401 last_modified: None,
402 git_status: None,
403 artifacts: Vec::new(),
404 total_size: 0,
405 cleanable_size: 0,
406 }
407 }
408
409 pub fn safety_check(&self) -> CleanSafety {
411 if let Some(status) = &self.git_status {
413 if status.has_uncommitted {
414 return CleanSafety::Warning(CleanWarning::UncommittedChanges {
415 paths: status.dirty_paths.clone(),
416 });
417 }
418 if status.has_untracked {
419 return CleanSafety::Warning(CleanWarning::UntrackedFiles);
420 }
421 } else {
422 return CleanSafety::Warning(CleanWarning::NotGitRepo);
423 }
424
425 if let Some(modified) = self.last_modified {
427 if let Ok(age) = modified.elapsed() {
428 let days = age.as_secs() / 86400;
429 if days < 7 {
430 return CleanSafety::Warning(CleanWarning::RecentlyModified {
431 age_days: days as u32,
432 });
433 }
434 }
435 }
436
437 CleanSafety::Safe
438 }
439
440 pub fn artifact_count(&self) -> usize {
442 self.artifacts.len()
443 }
444
445 pub fn calculate_totals(&mut self) {
447 self.total_size = self.artifacts.iter().map(|a| a.size).sum();
448 self.cleanable_size = self.total_size; }
450}
451
452impl std::fmt::Display for Project {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 write!(
455 f,
456 "{} {} ({}) - {}",
457 self.kind.icon(),
458 self.name,
459 self.kind.display_name(),
460 humansize::format_size(self.cleanable_size, humansize::BINARY)
461 )
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use std::path::PathBuf;
469
470 #[test]
471 fn test_project_id_from_path() {
472 let path1 = PathBuf::from("/home/user/project1");
473 let path2 = PathBuf::from("/home/user/project2");
474
475 let id1 = ProjectId::from_path(&path1);
476 let id2 = ProjectId::from_path(&path2);
477 let id1_again = ProjectId::from_path(&path1);
478
479 assert_eq!(id1, id1_again);
480 assert_ne!(id1, id2);
481 }
482
483 #[test]
484 fn test_marker_kind_matches() {
485 let temp = tempfile::tempdir().unwrap();
486 let path = temp.path();
487
488 std::fs::write(path.join("package.json"), "{}").unwrap();
490
491 let marker = MarkerKind::File("package.json");
492 assert!(marker.matches(path));
493
494 let marker = MarkerKind::File("cargo.toml");
495 assert!(!marker.matches(path));
496 }
497
498 #[test]
499 fn test_project_safety_check() {
500 let mut project = Project::new(ProjectKind::NodeNpm, PathBuf::from("/test"));
501
502 assert!(matches!(
504 project.safety_check(),
505 CleanSafety::Warning(CleanWarning::NotGitRepo)
506 ));
507
508 project.git_status = Some(GitStatus {
510 is_repo: true,
511 has_uncommitted: false,
512 has_untracked: false,
513 ..Default::default()
514 });
515 }
517}