Skip to main content

reflex/semantic/
context.rs

1//! Codebase context extraction for semantic query generation
2//!
3//! This module extracts rich context about the indexed codebase to help LLMs
4//! generate better search queries. Context includes language distribution,
5//! directory structure, monorepo detection, and more.
6
7use crate::cache::CacheManager;
8use anyhow::{Context as AnyhowContext, Result};
9use rusqlite::Connection;
10use std::collections::HashMap;
11use std::path::Path;
12
13/// Comprehensive codebase context for LLM prompt injection
14#[derive(Debug, Clone)]
15pub struct CodebaseContext {
16    /// Total number of indexed files
17    pub total_files: usize,
18
19    /// Language distribution with counts and percentages
20    pub languages: Vec<LanguageInfo>,
21
22    /// Top-level directories (first path segment)
23    pub top_level_dirs: Vec<String>,
24
25    /// Common path patterns (depth 2-3) for framework-aware suggestions
26    pub common_paths: Vec<String>,
27
28    /// Whether this appears to be a monorepo
29    pub is_monorepo: bool,
30
31    /// Number of detected projects in monorepo (if applicable)
32    pub project_count: Option<usize>,
33
34    /// Dominant language (if any language is >60% of files)
35    pub dominant_language: Option<LanguageInfo>,
36}
37
38/// Language information with count and percentage
39#[derive(Debug, Clone)]
40pub struct LanguageInfo {
41    pub name: String,
42    pub file_count: usize,
43    pub percentage: f64,
44}
45
46impl CodebaseContext {
47    /// Extract comprehensive context from cache
48    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        // Get total file count
54        let total_files: usize = conn
55            .query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0))
56            .unwrap_or(0);
57
58        // Extract language distribution
59        let languages = extract_language_distribution(&conn, total_files)?;
60
61        // Find dominant language (>60% of files)
62        let dominant_language = languages
63            .iter()
64            .find(|lang| lang.percentage > 60.0)
65            .cloned();
66
67        // Extract file paths for directory analysis
68        let file_paths = extract_file_paths(&conn)?;
69
70        // Analyze directory structure
71        let top_level_dirs = extract_top_level_dirs(&file_paths);
72        let common_paths = extract_common_paths(&file_paths, 2, 10); // depth 2-3, top 10
73
74        // Detect monorepo
75        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    /// Format context as a human-readable string for LLM prompt injection
89    pub fn to_prompt_string(&self) -> String {
90        let mut parts = Vec::new();
91
92        // Language distribution (Tier 1)
93        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        // File scale indicator (Tier 1)
108        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        // Top-level directories (Tier 1)
121        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        // Dominant language (Tier 2)
129        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        // Common paths (Tier 2)
137        if !self.common_paths.is_empty() {
138            let paths_str = self
139                .common_paths
140                .iter()
141                .take(8) // Limit to 8 most common
142                .map(|p| p.as_str())
143                .collect::<Vec<_>>()
144                .join(", ");
145            parts.push(format!("**Common paths:** {}", paths_str));
146        }
147
148        // Monorepo info (Tier 2)
149        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
162/// Extract language distribution with counts and percentages
163fn 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
196/// Extract all file paths from database
197fn 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
205/// Extract top-level directories (first path segment)
206fn 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    // Return top directories sorted by count (descending)
219    let mut dirs: Vec<(String, usize)> = dir_counts.into_iter().collect();
220    dirs.sort_by_key(|a| std::cmp::Reverse(a.1));
221
222    // Return top 10 directories with trailing slash
223    dirs.into_iter()
224        .take(10)
225        .map(|(dir, _)| format!("{}/", dir))
226        .collect()
227}
228
229/// Extract common path patterns at specified depth
230fn 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        // Extract paths at depth 2 and 3
237        for depth in min_depth..=3 {
238            if segments.len() > depth {
239                let partial_path = segments[..=depth].join("/");
240                // Skip if it's just a filename (no directory structure)
241                if !partial_path.contains('/') {
242                    continue;
243                }
244                // Skip hidden directories and common noise
245                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    // Filter to paths that appear at least 3 times (signal vs noise)
258    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    // Sort by count descending
265    common_paths.sort_by_key(|a| std::cmp::Reverse(a.1));
266
267    // Return top paths with trailing slash
268    common_paths
269        .into_iter()
270        .take(max_results)
271        .map(|(path, _)| format!("{}/", path))
272        .collect()
273}
274
275/// Detect if this is a monorepo by counting package manager files
276fn 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                // Skip root-level package files (not indicative of monorepo)
294                // Only count if in subdirectory (e.g., packages/foo/package.json)
295                if Path::new(path).components().count() > 2 {
296                    project_count += 1;
297                    break; // Don't double-count same project
298                }
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}