greppy/core/
project.rs

1//! Project detection and management
2
3use crate::core::config::Config;
4use crate::core::error::{Error, Result};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10/// Project root markers in priority order
11const PROJECT_MARKERS: &[&str] = &[
12    ".greppy",        // Explicit greppy marker
13    ".git",           // Git repository
14    "package.json",   // Node.js
15    "Cargo.toml",     // Rust
16    "pyproject.toml", // Python (modern)
17    "setup.py",       // Python (legacy)
18    "go.mod",         // Go
19    "pom.xml",        // Java Maven
20    "build.gradle",   // Java Gradle
21    "Gemfile",        // Ruby
22    "composer.json",  // PHP
23    "mix.exs",        // Elixir
24    "deno.json",      // Deno
25    "bun.lockb",      // Bun
26];
27
28/// Represents a detected project
29#[derive(Debug, Clone)]
30pub struct Project {
31    /// Absolute path to project root
32    pub root: PathBuf,
33    /// Type of project (based on marker found)
34    pub project_type: ProjectType,
35    /// Name of the project (directory name)
36    pub name: String,
37}
38
39/// Type of project based on detected marker
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum ProjectType {
42    Greppy,
43    Git,
44    NodeJs,
45    Rust,
46    Python,
47    Go,
48    Java,
49    Ruby,
50    Php,
51    Elixir,
52    Deno,
53    Bun,
54    Unknown,
55}
56
57impl Project {
58    /// Detect project from a path (searches upward for markers)
59    pub fn detect(start_path: &Path) -> Result<Self> {
60        let root = find_project_root(start_path)?;
61        let project_type = detect_project_type(&root);
62        let name = root
63            .file_name()
64            .map(|n| n.to_string_lossy().to_string())
65            .unwrap_or_else(|| "unknown".to_string());
66
67        Ok(Self {
68            root,
69            project_type,
70            name,
71        })
72    }
73
74    /// Create project from explicit path (must exist)
75    pub fn from_path(path: &Path) -> Result<Self> {
76        let root = path.canonicalize().map_err(|_| Error::ProjectNotFound {
77            path: path.to_path_buf(),
78        })?;
79
80        if !root.is_dir() {
81            return Err(Error::ProjectNotFound { path: root });
82        }
83
84        let project_type = detect_project_type(&root);
85        let name = root
86            .file_name()
87            .map(|n| n.to_string_lossy().to_string())
88            .unwrap_or_else(|| "unknown".to_string());
89
90        Ok(Self {
91            root,
92            project_type,
93            name,
94        })
95    }
96}
97
98/// Find project root by searching upward for markers
99fn find_project_root(start: &Path) -> Result<PathBuf> {
100    let start = if start.is_file() {
101        start.parent().unwrap_or(start)
102    } else {
103        start
104    };
105
106    let mut current = start.canonicalize().map_err(|_| Error::NoProjectRoot)?;
107
108    loop {
109        // Check for any project marker
110        for marker in PROJECT_MARKERS {
111            if current.join(marker).exists() {
112                return Ok(current);
113            }
114        }
115
116        // Move up to parent
117        match current.parent() {
118            Some(parent) => current = parent.to_path_buf(),
119            None => break,
120        }
121    }
122
123    Err(Error::NoProjectRoot)
124}
125
126/// Detect project type from root directory
127fn detect_project_type(root: &Path) -> ProjectType {
128    for (marker, project_type) in [
129        (".greppy", ProjectType::Greppy),
130        (".git", ProjectType::Git),
131        ("package.json", ProjectType::NodeJs),
132        ("Cargo.toml", ProjectType::Rust),
133        ("pyproject.toml", ProjectType::Python),
134        ("setup.py", ProjectType::Python),
135        ("go.mod", ProjectType::Go),
136        ("pom.xml", ProjectType::Java),
137        ("build.gradle", ProjectType::Java),
138        ("Gemfile", ProjectType::Ruby),
139        ("composer.json", ProjectType::Php),
140        ("mix.exs", ProjectType::Elixir),
141        ("deno.json", ProjectType::Deno),
142        ("bun.lockb", ProjectType::Bun),
143    ] {
144        if root.join(marker).exists() {
145            return project_type;
146        }
147    }
148
149    ProjectType::Unknown
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use tempfile::TempDir;
156
157    #[test]
158    fn test_find_project_root_git() {
159        let temp = TempDir::new().unwrap();
160        let git_dir = temp.path().join(".git");
161        std::fs::create_dir(&git_dir).unwrap();
162
163        let nested = temp.path().join("src").join("deep").join("nested");
164        std::fs::create_dir_all(&nested).unwrap();
165
166        let project = Project::detect(&nested).unwrap();
167        assert_eq!(project.root, temp.path().canonicalize().unwrap());
168        assert_eq!(project.project_type, ProjectType::Git);
169    }
170
171    #[test]
172    fn test_find_project_root_cargo() {
173        let temp = TempDir::new().unwrap();
174        std::fs::write(temp.path().join("Cargo.toml"), "[package]").unwrap();
175
176        let project = Project::detect(temp.path()).unwrap();
177        assert_eq!(project.project_type, ProjectType::Rust);
178    }
179
180    #[test]
181    fn test_no_project_root() {
182        let temp = TempDir::new().unwrap();
183        let result = Project::detect(temp.path());
184        assert!(result.is_err());
185    }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ProjectEntry {
190    pub path: PathBuf,
191    pub name: String,
192    pub indexed_at: SystemTime,
193    pub file_count: usize,
194    pub chunk_count: usize,
195    pub watching: bool,
196}
197
198#[derive(Debug, Default, Serialize, Deserialize)]
199pub struct Registry {
200    pub projects: HashMap<String, ProjectEntry>,
201}
202
203impl Registry {
204    /// Load registry from disk
205    pub fn load() -> Result<Self> {
206        let path = Config::registry_path()?;
207        if !path.exists() {
208            return Ok(Self::default());
209        }
210
211        let content = std::fs::read_to_string(&path)?;
212        let registry: Registry = serde_json::from_str(&content)?;
213        Ok(registry)
214    }
215
216    /// Save registry to disk
217    pub fn save(&self) -> Result<()> {
218        Config::ensure_home()?;
219        let path = Config::registry_path()?;
220        let content = serde_json::to_string_pretty(self)?;
221        std::fs::write(&path, content)?;
222        Ok(())
223    }
224
225    /// Get project key (hash of path)
226    fn key(path: &Path) -> String {
227        let hash = xxhash_rust::xxh3::xxh3_64(path.to_string_lossy().as_bytes());
228        format!("{:016x}", hash)
229    }
230
231    /// Add or update a project
232    pub fn upsert(&mut self, entry: ProjectEntry) {
233        let key = Self::key(&entry.path);
234        self.projects.insert(key, entry);
235    }
236
237    /// Get a project by path
238    pub fn get(&self, path: &Path) -> Option<&ProjectEntry> {
239        let key = Self::key(path);
240        self.projects.get(&key)
241    }
242
243    /// Remove a project
244    pub fn remove(&mut self, path: &Path) -> Option<ProjectEntry> {
245        let key = Self::key(path);
246        self.projects.remove(&key)
247    }
248
249    /// List all projects
250    pub fn list(&self) -> Vec<&ProjectEntry> {
251        self.projects.values().collect()
252    }
253
254    /// Set watching status
255    pub fn set_watching(&mut self, path: &Path, watching: bool) {
256        let key = Self::key(path);
257        if let Some(entry) = self.projects.get_mut(&key) {
258            entry.watching = watching;
259        }
260    }
261}