1use 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
10const PROJECT_MARKERS: &[&str] = &[
12 ".greppy", ".git", "package.json", "Cargo.toml", "pyproject.toml", "setup.py", "go.mod", "pom.xml", "build.gradle", "Gemfile", "composer.json", "mix.exs", "deno.json", "bun.lockb", ];
27
28#[derive(Debug, Clone)]
30pub struct Project {
31 pub root: PathBuf,
33 pub project_type: ProjectType,
35 pub name: String,
37}
38
39#[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 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 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
98fn 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 for marker in PROJECT_MARKERS {
111 if current.join(marker).exists() {
112 return Ok(current);
113 }
114 }
115
116 match current.parent() {
118 Some(parent) => current = parent.to_path_buf(),
119 None => break,
120 }
121 }
122
123 Err(Error::NoProjectRoot)
124}
125
126fn 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 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 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 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 pub fn upsert(&mut self, entry: ProjectEntry) {
233 let key = Self::key(&entry.path);
234 self.projects.insert(key, entry);
235 }
236
237 pub fn get(&self, path: &Path) -> Option<&ProjectEntry> {
239 let key = Self::key(path);
240 self.projects.get(&key)
241 }
242
243 pub fn remove(&mut self, path: &Path) -> Option<ProjectEntry> {
245 let key = Self::key(path);
246 self.projects.remove(&key)
247 }
248
249 pub fn list(&self) -> Vec<&ProjectEntry> {
251 self.projects.values().collect()
252 }
253
254 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}