1use crate::cache::CacheManager;
8use anyhow::{Context as AnyhowContext, Result};
9use rusqlite::Connection;
10use std::collections::HashMap;
11use std::path::Path;
12
13#[derive(Debug, Clone)]
15pub struct CodebaseContext {
16 pub total_files: usize,
18
19 pub languages: Vec<LanguageInfo>,
21
22 pub top_level_dirs: Vec<String>,
24
25 pub common_paths: Vec<String>,
27
28 pub is_monorepo: bool,
30
31 pub project_count: Option<usize>,
33
34 pub dominant_language: Option<LanguageInfo>,
36}
37
38#[derive(Debug, Clone)]
40pub struct LanguageInfo {
41 pub name: String,
42 pub file_count: usize,
43 pub percentage: f64,
44}
45
46impl CodebaseContext {
47 pub fn extract(cache: &CacheManager) -> Result<Self> {
49 let db_path = cache.path().join("meta.db");
50 let conn =
51 Connection::open(&db_path).context("Failed to open database for context extraction")?;
52
53 let total_files: usize = conn
55 .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))
56 .unwrap_or(0);
57
58 let languages = extract_language_distribution(&conn, total_files)?;
60
61 let dominant_language = languages
63 .iter()
64 .find(|lang| lang.percentage > 60.0)
65 .cloned();
66
67 let file_paths = extract_file_paths(&conn)?;
69
70 let top_level_dirs = extract_top_level_dirs(&file_paths);
72 let common_paths = extract_common_paths(&file_paths, 2, 10); let (is_monorepo, project_count) = detect_monorepo(&file_paths);
76
77 Ok(Self {
78 total_files,
79 languages,
80 top_level_dirs,
81 common_paths,
82 is_monorepo,
83 project_count,
84 dominant_language,
85 })
86 }
87
88 pub fn to_prompt_string(&self) -> String {
90 let mut parts = Vec::new();
91
92 if !self.languages.is_empty() {
94 let lang_summary: Vec<String> = self
95 .languages
96 .iter()
97 .map(|lang| {
98 format!(
99 "{} ({} files, {:.0}%)",
100 lang.name, lang.file_count, lang.percentage
101 )
102 })
103 .collect();
104 parts.push(format!("**Languages:** {}", lang_summary.join(", ")));
105 }
106
107 let scale_hint = if self.total_files < 100 {
109 "small codebase - broad queries work well"
110 } else if self.total_files < 1000 {
111 "medium codebase - moderate specificity recommended"
112 } else {
113 "large codebase - use specific filters for best results"
114 };
115 parts.push(format!(
116 "**Total files:** {} ({})",
117 self.total_files, scale_hint
118 ));
119
120 if !self.top_level_dirs.is_empty() {
122 parts.push(format!(
123 "**Top-level directories:** {}",
124 self.top_level_dirs.join(", ")
125 ));
126 }
127
128 if let Some(ref dominant) = self.dominant_language {
130 parts.push(format!(
131 "**Primary language:** {} ({:.0}% of codebase)",
132 dominant.name, dominant.percentage
133 ));
134 }
135
136 if !self.common_paths.is_empty() {
138 let paths_str = self
139 .common_paths
140 .iter()
141 .take(8) .map(|p| p.as_str())
143 .collect::<Vec<_>>()
144 .join(", ");
145 parts.push(format!("**Common paths:** {}", paths_str));
146 }
147
148 if self.is_monorepo {
150 if let Some(count) = self.project_count {
151 parts.push(format!("**Monorepo:** Yes ({} projects detected - use --file to target specific projects)", count));
152 } else {
153 parts
154 .push("**Monorepo:** Yes (use --file to target specific projects)".to_string());
155 }
156 }
157
158 parts.join("\n")
159 }
160}
161
162fn extract_language_distribution(
164 conn: &Connection,
165 total_files: usize,
166) -> Result<Vec<LanguageInfo>> {
167 let mut stmt = conn.prepare(
168 "SELECT language, COUNT(*) as count
169 FROM files
170 WHERE language IS NOT NULL
171 GROUP BY language
172 ORDER BY count DESC",
173 )?;
174
175 let languages = stmt
176 .query_map([], |row| {
177 let name: String = row.get(0)?;
178 let file_count: usize = row.get(1)?;
179 let percentage = if total_files > 0 {
180 (file_count as f64 / total_files as f64) * 100.0
181 } else {
182 0.0
183 };
184
185 Ok(LanguageInfo {
186 name,
187 file_count,
188 percentage,
189 })
190 })?
191 .collect::<Result<Vec<_>, _>>()?;
192
193 Ok(languages)
194}
195
196fn extract_file_paths(conn: &Connection) -> Result<Vec<String>> {
198 let mut stmt = conn.prepare("SELECT path FROM files")?;
199 let paths = stmt
200 .query_map([], |row| row.get(0))?
201 .collect::<Result<Vec<_>, _>>()?;
202 Ok(paths)
203}
204
205fn extract_top_level_dirs(paths: &[String]) -> Vec<String> {
207 let mut dir_counts: HashMap<String, usize> = HashMap::new();
208
209 for path in paths {
210 if let Some(first_segment) = path.split('/').next()
211 && !first_segment.is_empty()
212 && !first_segment.starts_with('.')
213 {
214 *dir_counts.entry(first_segment.to_string()).or_insert(0) += 1;
215 }
216 }
217
218 let mut dirs: Vec<(String, usize)> = dir_counts.into_iter().collect();
220 dirs.sort_by_key(|a| std::cmp::Reverse(a.1));
221
222 dirs.into_iter()
224 .take(10)
225 .map(|(dir, _)| format!("{}/", dir))
226 .collect()
227}
228
229fn extract_common_paths(paths: &[String], min_depth: usize, max_results: usize) -> Vec<String> {
231 let mut path_counts: HashMap<String, usize> = HashMap::new();
232
233 for path in paths {
234 let segments: Vec<&str> = path.split('/').collect();
235
236 for depth in min_depth..=3 {
238 if segments.len() > depth {
239 let partial_path = segments[..=depth].join("/");
240 if !partial_path.contains('/') {
242 continue;
243 }
244 if partial_path.contains("/.")
246 || partial_path.contains("/node_modules")
247 || partial_path.contains("/vendor")
248 || partial_path.contains("/target")
249 {
250 continue;
251 }
252 *path_counts.entry(partial_path).or_insert(0) += 1;
253 }
254 }
255 }
256
257 let min_count = 3;
259 let mut common_paths: Vec<(String, usize)> = path_counts
260 .into_iter()
261 .filter(|(_, count)| *count >= min_count)
262 .collect();
263
264 common_paths.sort_by_key(|a| std::cmp::Reverse(a.1));
266
267 common_paths
269 .into_iter()
270 .take(max_results)
271 .map(|(path, _)| format!("{}/", path))
272 .collect()
273}
274
275fn detect_monorepo(paths: &[String]) -> (bool, Option<usize>) {
277 let package_files = [
278 "package.json",
279 "Cargo.toml",
280 "go.mod",
281 "composer.json",
282 "pom.xml",
283 "build.gradle",
284 "Gemfile",
285 ];
286
287 let mut project_count = 0;
288
289 for path in paths {
290 let path_lower = path.to_lowercase();
291 for pkg_file in &package_files {
292 if path_lower.ends_with(pkg_file) {
293 if Path::new(path).components().count() > 2 {
296 project_count += 1;
297 break; }
299 }
300 }
301 }
302
303 let is_monorepo = project_count >= 2;
304 let project_count_opt = if is_monorepo {
305 Some(project_count)
306 } else {
307 None
308 };
309
310 (is_monorepo, project_count_opt)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_extract_top_level_dirs() {
319 let paths = vec![
320 "src/main.rs".to_string(),
321 "src/lib.rs".to_string(),
322 "app/models/user.rb".to_string(),
323 "app/controllers/home.rb".to_string(),
324 "tests/test.rs".to_string(),
325 ];
326
327 let dirs = extract_top_level_dirs(&paths);
328 assert_eq!(dirs.len(), 3);
329 assert!(dirs.contains(&"src/".to_string()));
330 assert!(dirs.contains(&"app/".to_string()));
331 assert!(dirs.contains(&"tests/".to_string()));
332 }
333
334 #[test]
335 fn test_extract_common_paths() {
336 let paths = vec![
337 "app/models/user.rb".to_string(),
338 "app/models/post.rb".to_string(),
339 "app/models/comment.rb".to_string(),
340 "app/models/article.rb".to_string(),
341 "app/controllers/home.rb".to_string(),
342 "app/controllers/posts.rb".to_string(),
343 "app/controllers/articles.rb".to_string(),
344 "app/controllers/users.rb".to_string(),
345 "src/main.rs".to_string(),
346 ];
347
348 let common = extract_common_paths(&paths, 1, 10);
349 assert!(common.contains(&"app/models/".to_string()));
350 assert!(common.contains(&"app/controllers/".to_string()));
351 }
352
353 #[test]
354 fn test_detect_monorepo() {
355 let monorepo_paths = vec![
356 "packages/web/package.json".to_string(),
357 "packages/api/package.json".to_string(),
358 "packages/shared/package.json".to_string(),
359 ];
360
361 let (is_monorepo, count) = detect_monorepo(&monorepo_paths);
362 assert!(is_monorepo);
363 assert_eq!(count, Some(3));
364
365 let single_project = vec!["package.json".to_string(), "src/main.ts".to_string()];
366
367 let (is_mono, _) = detect_monorepo(&single_project);
368 assert!(!is_mono);
369 }
370
371 #[test]
372 fn test_language_percentage() {
373 let lang = LanguageInfo {
374 name: "Rust".to_string(),
375 file_count: 64,
376 percentage: 64.0,
377 };
378
379 assert_eq!(lang.percentage, 64.0);
380 }
381}