llm_git/
repo.rs

1//! Repository metadata detection
2//!
3//! Detects project type, language, and framework from manifest files
4//! to provide context for commit message generation.
5
6use std::path::Path;
7
8/// Detected repository metadata
9#[derive(Debug, Clone, Default)]
10pub struct RepoMetadata {
11   /// Primary programming language
12   pub language:        Option<String>,
13   /// Web/backend framework if detected
14   pub framework:       Option<String>,
15   /// Package manager used
16   pub package_manager: Option<String>,
17   /// Whether this is a monorepo/workspace
18   pub is_monorepo:     bool,
19   /// Number of packages/crates (for monorepos)
20   pub package_count:   Option<usize>,
21}
22
23impl RepoMetadata {
24   /// Detect repository metadata from the given directory
25   pub fn detect(dir: &Path) -> Self {
26      let mut meta = Self::default();
27
28      // Check for Rust project
29      if let Some(rust_meta) = detect_rust(dir) {
30         meta = rust_meta;
31      }
32      // Check for Node.js/TypeScript project
33      else if let Some(node_meta) = detect_node(dir) {
34         meta = node_meta;
35      }
36      // Check for Python project
37      else if let Some(python_meta) = detect_python(dir) {
38         meta = python_meta;
39      }
40      // Check for Go project
41      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   /// Format metadata for prompt injection
50   pub fn format_for_prompt(&self) -> Option<String> {
51      self.language.as_ref()?;
52
53      let mut lines = Vec::new();
54
55      // Language line
56      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      // Framework line
69      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
81/// Detect Rust project metadata
82fn 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   // Check for workspace
96   if content.contains("[workspace]") {
97      meta.is_monorepo = true;
98
99      // Count workspace members
100      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   // Detect framework from dependencies
112   let framework = detect_rust_framework(&content);
113   if framework.is_some() {
114      meta.framework = framework;
115   }
116
117   Some(meta)
118}
119
120/// Detect Rust framework from Cargo.toml dependencies
121fn detect_rust_framework(content: &str) -> Option<String> {
122   // Check for common web frameworks (order matters - first match wins)
123   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      // Match "dep_name" or "dep-name" in dependencies
144      if content.contains(&format!("\"{dep}\"")) || content.contains(&format!("{dep} =")) {
145         return Some(name.to_string());
146      }
147   }
148
149   None
150}
151
152/// Detect Node.js/TypeScript project metadata
153fn 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   // Determine if TypeScript
162   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   // Detect package manager
173   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   // Check for workspaces
184   if content.contains("\"workspaces\"") || dir.join("pnpm-workspace.yaml").exists() {
185      meta.is_monorepo = true;
186   }
187
188   // Detect framework
189   let framework = detect_node_framework(&content);
190   if framework.is_some() {
191      meta.framework = framework;
192   }
193
194   Some(meta)
195}
196
197/// Detect Node.js framework from package.json
198fn 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
226/// Detect Python project metadata
227fn 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   // Detect package manager
239   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      // Detect framework
252      meta.framework = detect_python_framework(&content);
253   } else {
254      meta.package_manager = Some("pip".to_string());
255   }
256
257   Some(meta)
258}
259
260/// Detect Python framework from pyproject.toml
261fn 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
287/// Check if directory is a Go project
288fn 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}