Skip to main content

null_e/core/
project.rs

1//! Project detection and representation
2//!
3//! A Project represents a detected development project directory,
4//! including its type, artifacts, and safety status.
5
6use 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/// Unique identifier for a detected project
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct ProjectId(pub u64);
17
18impl ProjectId {
19    /// Create a project ID from a path
20    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/// The type/ecosystem of a detected project
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[non_exhaustive]
36pub enum ProjectKind {
37    // ═══════════════════════════════════════════════════════════════
38    // JavaScript/TypeScript Ecosystem
39    // ═══════════════════════════════════════════════════════════════
40    NodeNpm,
41    NodeYarn,
42    NodePnpm,
43    NodeBun,
44    Deno,
45
46    // ═══════════════════════════════════════════════════════════════
47    // Systems Languages
48    // ═══════════════════════════════════════════════════════════════
49    Rust,
50    Go,
51    Cpp,
52    C,
53    Zig,
54
55    // ═══════════════════════════════════════════════════════════════
56    // JVM Ecosystem
57    // ═══════════════════════════════════════════════════════════════
58    JavaMaven,
59    JavaGradle,
60    Kotlin,
61    Scala,
62    Clojure,
63
64    // ═══════════════════════════════════════════════════════════════
65    // .NET Ecosystem
66    // ═══════════════════════════════════════════════════════════════
67    DotNet,
68    FSharp,
69
70    // ═══════════════════════════════════════════════════════════════
71    // Python Ecosystem
72    // ═══════════════════════════════════════════════════════════════
73    PythonPip,
74    PythonPoetry,
75    PythonPipenv,
76    PythonConda,
77    PythonUv,
78
79    // ═══════════════════════════════════════════════════════════════
80    // Ruby Ecosystem
81    // ═══════════════════════════════════════════════════════════════
82    RubyBundler,
83    RubyRails,
84
85    // ═══════════════════════════════════════════════════════════════
86    // PHP Ecosystem
87    // ═══════════════════════════════════════════════════════════════
88    PhpComposer,
89    PhpLaravel,
90
91    // ═══════════════════════════════════════════════════════════════
92    // Mobile Development
93    // ═══════════════════════════════════════════════════════════════
94    SwiftSpm,
95    SwiftXcode,
96    Flutter,
97    ReactNative,
98    Android,
99
100    // ═══════════════════════════════════════════════════════════════
101    // Other Languages
102    // ═══════════════════════════════════════════════════════════════
103    Elixir,
104    Haskell,
105    OCaml,
106    Julia,
107    R,
108    Lua,
109    Perl,
110
111    // ═══════════════════════════════════════════════════════════════
112    // Infrastructure as Code
113    // ═══════════════════════════════════════════════════════════════
114    Terraform,
115    Pulumi,
116
117    // ═══════════════════════════════════════════════════════════════
118    // Containers
119    // ═══════════════════════════════════════════════════════════════
120    Docker,
121
122    /// Custom plugin-defined project type
123    Custom(u32),
124}
125
126impl ProjectKind {
127    /// Get a human-readable name for this project kind
128    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    /// Get the icon/emoji for this project kind
176    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    /// Check if this is a Node.js/JavaScript project
206    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    /// Check if this is a Rust project
214    pub fn is_rust(&self) -> bool {
215        matches!(self, Self::Rust)
216    }
217
218    /// Check if this is a Python project
219    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    /// Check if this is a Java/JVM project
231    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    /// Check if this is a Go project
239    pub fn is_go(&self) -> bool {
240        matches!(self, Self::Go)
241    }
242
243    /// Check if this is a Swift/iOS project
244    pub fn is_swift(&self) -> bool {
245        matches!(self, Self::SwiftSpm | Self::SwiftXcode)
246    }
247
248    /// Check if this is a .NET project
249    pub fn is_dotnet(&self) -> bool {
250        matches!(self, Self::DotNet | Self::FSharp)
251    }
252}
253
254/// Marker files/directories that identify a project type
255#[derive(Debug, Clone)]
256pub struct ProjectMarker {
257    /// What to look for
258    pub indicator: MarkerKind,
259    /// The project kind this marker identifies
260    pub kind: ProjectKind,
261    /// Priority when multiple markers match (higher = preferred)
262    pub priority: u8,
263}
264
265/// Types of markers that can identify a project
266#[derive(Debug, Clone)]
267pub enum MarkerKind {
268    /// Exact filename match (e.g., "package.json")
269    File(&'static str),
270    /// Exact directory name match (e.g., "node_modules")
271    Directory(&'static str),
272    /// File with specific extension
273    Extension(&'static str),
274    /// Multiple files (all must exist)
275    AllOf(Vec<&'static str>),
276    /// Multiple files (any must exist)
277    AnyOf(Vec<&'static str>),
278}
279
280impl MarkerKind {
281    /// Check if this marker matches at the given path
282    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/// Git repository status for safety checks
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct GitStatus {
300    /// Whether this is a git repository
301    pub is_repo: bool,
302    /// Has uncommitted changes (modified/staged files)
303    pub has_uncommitted: bool,
304    /// Has untracked files
305    pub has_untracked: bool,
306    /// Has stashed changes
307    pub has_stashed: bool,
308    /// Current branch name
309    pub branch: Option<String>,
310    /// Remote URL (if any)
311    pub remote: Option<String>,
312    /// Last commit timestamp
313    pub last_commit: Option<SystemTime>,
314    /// Paths with uncommitted changes
315    pub dirty_paths: Vec<PathBuf>,
316}
317
318impl GitStatus {
319    /// Check if the repository is completely clean
320    pub fn is_clean(&self) -> bool {
321        self.is_repo && !self.has_uncommitted && !self.has_untracked
322    }
323}
324
325/// Safety status for cleaning operations
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub enum CleanSafety {
328    /// Safe to clean without concerns
329    Safe,
330    /// Safe but with a warning
331    Warning(CleanWarning),
332    /// Blocked - should not clean without override
333    Blocked(CleanBlock),
334}
335
336/// Warning types for cleaning operations
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub enum CleanWarning {
339    /// Project has uncommitted changes
340    UncommittedChanges { paths: Vec<PathBuf> },
341    /// Project has untracked files
342    UntrackedFiles,
343    /// Not a git repository (can't verify safety)
344    NotGitRepo,
345    /// Recently modified
346    RecentlyModified { age_days: u32 },
347    /// No lockfile found
348    NoLockfile,
349}
350
351/// Blocking reasons for cleaning operations
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum CleanBlock {
354    /// Active lock file present (process using it)
355    LockFilePresent(PathBuf),
356    /// Process actively using the directory
357    ProcessRunning { pid: u32, name: String },
358    /// User explicitly protected this path
359    UserProtected,
360}
361
362/// A detected development project
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct Project {
365    /// Unique identifier
366    pub id: ProjectId,
367    /// Project ecosystem/type
368    pub kind: ProjectKind,
369    /// Root directory path
370    pub root: PathBuf,
371    /// Project name (usually directory name)
372    pub name: String,
373    /// Last modification time (skipped in serialization)
374    #[serde(skip)]
375    pub last_modified: Option<SystemTime>,
376    /// Git status (if available, skipped in serialization)
377    #[serde(skip)]
378    pub git_status: Option<GitStatus>,
379    /// Cleanable artifacts found
380    pub artifacts: Vec<Artifact>,
381    /// Total size of all artifacts
382    pub total_size: u64,
383    /// Size that can be cleaned
384    pub cleanable_size: u64,
385}
386
387impl Project {
388    /// Create a new project
389    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    /// Check if it's safe to clean this project
410    pub fn safety_check(&self) -> CleanSafety {
411        // Check git status
412        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        // Check age
426        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    /// Get the number of artifacts
441    pub fn artifact_count(&self) -> usize {
442        self.artifacts.len()
443    }
444
445    /// Calculate totals from artifacts
446    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; // Can apply rules later
449    }
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        // Create a file
489        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        // No git status = warning
503        assert!(matches!(
504            project.safety_check(),
505            CleanSafety::Warning(CleanWarning::NotGitRepo)
506        ));
507
508        // Clean git status = safe
509        project.git_status = Some(GitStatus {
510            is_repo: true,
511            has_uncommitted: false,
512            has_untracked: false,
513            ..Default::default()
514        });
515        // Would be safe if not recently modified
516    }
517}