1use std::path::Path;
7
8#[derive(Debug, Clone, Default)]
10pub struct RepoMetadata {
11 pub language: Option<String>,
13 pub framework: Option<String>,
15 pub package_manager: Option<String>,
17 pub is_monorepo: bool,
19 pub package_count: Option<usize>,
21}
22
23impl RepoMetadata {
24 pub fn detect(dir: &Path) -> Self {
26 let mut meta = Self::default();
27
28 if let Some(rust_meta) = detect_rust(dir) {
30 meta = rust_meta;
31 }
32 else if let Some(node_meta) = detect_node(dir) {
34 meta = node_meta;
35 }
36 else if let Some(python_meta) = detect_python(dir) {
38 meta = python_meta;
39 }
40 else if detect_go(dir) {
42 meta.language = Some("Go".to_string());
43 meta.package_manager = Some("go mod".to_string());
44 }
45
46 meta
47 }
48
49 pub fn format_for_prompt(&self) -> Option<String> {
51 self.language.as_ref()?;
52
53 let mut lines = Vec::new();
54
55 if let Some(lang) = &self.language {
57 let mut lang_str = lang.clone();
58 if self.is_monorepo {
59 if let Some(count) = self.package_count {
60 lang_str = format!("{lang} (workspace, {count} packages)");
61 } else {
62 lang_str = format!("{lang} (workspace)");
63 }
64 }
65 lines.push(format!("Language: {lang_str}"));
66 }
67
68 if let Some(framework) = &self.framework {
70 lines.push(format!("Framework: {framework}"));
71 }
72
73 if lines.is_empty() {
74 None
75 } else {
76 Some(lines.join("\n"))
77 }
78 }
79}
80
81fn detect_rust(dir: &Path) -> Option<RepoMetadata> {
83 let cargo_toml = dir.join("Cargo.toml");
84 if !cargo_toml.exists() {
85 return None;
86 }
87
88 let content = std::fs::read_to_string(&cargo_toml).ok()?;
89 let mut meta = RepoMetadata {
90 language: Some("Rust".to_string()),
91 package_manager: Some("cargo".to_string()),
92 ..Default::default()
93 };
94
95 if content.contains("[workspace]") {
97 meta.is_monorepo = true;
98
99 if let Some(members_start) = content.find("members")
101 && let Some(bracket_start) = content[members_start..].find('[')
102 {
103 let rest = &content[members_start + bracket_start..];
104 if let Some(bracket_end) = rest.find(']') {
105 let members_str = &rest[1..bracket_end];
106 meta.package_count = Some(members_str.matches('"').count() / 2);
107 }
108 }
109 }
110
111 let framework = detect_rust_framework(&content);
113 if framework.is_some() {
114 meta.framework = framework;
115 }
116
117 Some(meta)
118}
119
120fn detect_rust_framework(content: &str) -> Option<String> {
122 let frameworks = [
124 ("axum", "Axum"),
125 ("actix-web", "Actix Web"),
126 ("rocket", "Rocket"),
127 ("warp", "Warp"),
128 ("tide", "Tide"),
129 ("poem", "Poem"),
130 ("tower-http", "Tower HTTP"),
131 ("hyper", "Hyper"),
132 ("tokio", "Tokio async runtime"),
133 ("bevy", "Bevy game engine"),
134 ("iced", "Iced GUI"),
135 ("egui", "egui GUI"),
136 ("tauri", "Tauri"),
137 ("leptos", "Leptos"),
138 ("yew", "Yew"),
139 ("dioxus", "Dioxus"),
140 ];
141
142 for (dep, name) in frameworks {
143 if content.contains(&format!("\"{dep}\"")) || content.contains(&format!("{dep} =")) {
145 return Some(name.to_string());
146 }
147 }
148
149 None
150}
151
152fn detect_node(dir: &Path) -> Option<RepoMetadata> {
154 let package_json = dir.join("package.json");
155 if !package_json.exists() {
156 return None;
157 }
158
159 let content = std::fs::read_to_string(&package_json).ok()?;
160
161 let is_typescript = content.contains("\"typescript\"") || dir.join("tsconfig.json").exists();
163
164 let language = if is_typescript {
165 "TypeScript"
166 } else {
167 "JavaScript"
168 };
169
170 let mut meta = RepoMetadata { language: Some(language.to_string()), ..Default::default() };
171
172 if dir.join("pnpm-lock.yaml").exists() {
174 meta.package_manager = Some("pnpm".to_string());
175 } else if dir.join("yarn.lock").exists() {
176 meta.package_manager = Some("yarn".to_string());
177 } else if dir.join("bun.lockb").exists() {
178 meta.package_manager = Some("bun".to_string());
179 } else {
180 meta.package_manager = Some("npm".to_string());
181 }
182
183 if content.contains("\"workspaces\"") || dir.join("pnpm-workspace.yaml").exists() {
185 meta.is_monorepo = true;
186 }
187
188 let framework = detect_node_framework(&content);
190 if framework.is_some() {
191 meta.framework = framework;
192 }
193
194 Some(meta)
195}
196
197fn detect_node_framework(content: &str) -> Option<String> {
199 let frameworks = [
200 ("next", "Next.js"),
201 ("nuxt", "Nuxt"),
202 ("@angular/core", "Angular"),
203 ("vue", "Vue"),
204 ("react", "React"),
205 ("svelte", "Svelte"),
206 ("solid-js", "SolidJS"),
207 ("express", "Express"),
208 ("fastify", "Fastify"),
209 ("hono", "Hono"),
210 ("nestjs", "NestJS"),
211 ("@nestjs/core", "NestJS"),
212 ("electron", "Electron"),
213 ("expo", "Expo"),
214 ("react-native", "React Native"),
215 ];
216
217 for (dep, name) in frameworks {
218 if content.contains(&format!("\"{dep}\"")) {
219 return Some(name.to_string());
220 }
221 }
222
223 None
224}
225
226fn detect_python(dir: &Path) -> Option<RepoMetadata> {
228 let pyproject = dir.join("pyproject.toml");
229 let setup_py = dir.join("setup.py");
230 let requirements = dir.join("requirements.txt");
231
232 if !pyproject.exists() && !setup_py.exists() && !requirements.exists() {
233 return None;
234 }
235
236 let mut meta = RepoMetadata { language: Some("Python".to_string()), ..Default::default() };
237
238 if pyproject.exists() {
240 let content = std::fs::read_to_string(&pyproject).unwrap_or_default();
241 if content.contains("[tool.poetry]") {
242 meta.package_manager = Some("poetry".to_string());
243 } else if content.contains("[tool.uv]") || dir.join("uv.lock").exists() {
244 meta.package_manager = Some("uv".to_string());
245 } else if content.contains("[tool.pdm]") {
246 meta.package_manager = Some("pdm".to_string());
247 } else {
248 meta.package_manager = Some("pip".to_string());
249 }
250
251 meta.framework = detect_python_framework(&content);
253 } else {
254 meta.package_manager = Some("pip".to_string());
255 }
256
257 Some(meta)
258}
259
260fn detect_python_framework(content: &str) -> Option<String> {
262 let frameworks = [
263 ("fastapi", "FastAPI"),
264 ("django", "Django"),
265 ("flask", "Flask"),
266 ("starlette", "Starlette"),
267 ("litestar", "Litestar"),
268 ("sanic", "Sanic"),
269 ("tornado", "Tornado"),
270 ("aiohttp", "aiohttp"),
271 ("pytorch", "PyTorch"),
272 ("torch", "PyTorch"),
273 ("tensorflow", "TensorFlow"),
274 ("jax", "JAX"),
275 ("transformers", "Hugging Face"),
276 ];
277
278 for (dep, name) in frameworks {
279 if content.to_lowercase().contains(dep) {
280 return Some(name.to_string());
281 }
282 }
283
284 None
285}
286
287fn detect_go(dir: &Path) -> bool {
289 dir.join("go.mod").exists()
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn test_format_for_prompt_empty() {
298 let meta = RepoMetadata::default();
299 assert!(meta.format_for_prompt().is_none());
300 }
301
302 #[test]
303 fn test_format_for_prompt_rust() {
304 let meta = RepoMetadata {
305 language: Some("Rust".to_string()),
306 framework: Some("Axum".to_string()),
307 package_manager: Some("cargo".to_string()),
308 is_monorepo: true,
309 package_count: Some(5),
310 };
311
312 let formatted = meta.format_for_prompt().unwrap();
313 assert!(formatted.contains("Rust (workspace, 5 packages)"));
314 assert!(formatted.contains("Framework: Axum"));
315 }
316}