Skip to main content

ctx/
smart.rs

1//! Smart context selection for LLM workflows.
2//!
3//! This module provides intelligent file selection based on:
4//! - Semantic search using embeddings
5//! - Call graph expansion (callers and callees)
6//! - Token budget management
7//!
8//! # Usage
9//!
10//! ```ignore
11//! let config = SmartConfig::default();
12//! let result = smart_context(&db, &analytics, &provider, "add caching", config)?;
13//! for file in result.selected_files {
14//!     println!("{}: {} tokens", file.path, file.token_count);
15//! }
16//! ```
17
18use std::collections::HashMap;
19use std::path::Path;
20
21use crate::analytics::{Analytics, CallGraphNode, ImpactNode};
22use crate::db::Database;
23use crate::embeddings::{semantic_search, Embedding, EmbeddingProvider, SearchResult};
24use crate::error::{CtxError, Result};
25use crate::tokens::{count_file_tokens, select_by_token_budget, Encoding, HasTokenCount};
26
27/// Configuration for smart context selection.
28#[derive(Debug, Clone)]
29pub struct SmartConfig {
30    /// Maximum tokens in output
31    pub max_tokens: usize,
32    /// Call graph expansion depth
33    pub depth: i32,
34    /// Number of initial semantic matches to find
35    pub top: usize,
36    /// Tokenizer encoding to use
37    pub encoding: Encoding,
38}
39
40impl Default for SmartConfig {
41    fn default() -> Self {
42        Self {
43            max_tokens: 8000,
44            depth: 2,
45            top: 10,
46            encoding: Encoding::default(),
47        }
48    }
49}
50
51/// Reason why a file was selected.
52#[derive(Debug, Clone)]
53pub enum SelectionReason {
54    /// Direct semantic match to task embedding
55    SemanticMatch {
56        /// The symbol that matched
57        symbol: String,
58        /// Similarity score (0.0-1.0)
59        score: f32,
60    },
61    /// Called by a matched symbol
62    CalledBy {
63        /// The symbol that calls this file's code
64        caller: String,
65        /// Depth in call graph
66        depth: i32,
67    },
68    /// Calls a matched symbol
69    Calls {
70        /// The symbol that this file calls
71        callee: String,
72        /// Depth in call graph
73        depth: i32,
74    },
75    /// In the same module as a matched symbol
76    #[allow(dead_code)] // Used in tests and pattern matching
77    SameModule {
78        /// The related symbol
79        symbol: String,
80    },
81    /// User explicitly requested this file
82    #[allow(dead_code)] // Used in tests and pattern matching
83    Explicit,
84}
85
86impl SelectionReason {
87    /// Get a human-readable description of the reason.
88    pub fn description(&self) -> String {
89        match self {
90            SelectionReason::SemanticMatch { symbol, score } => {
91                format!("SemanticMatch: \"{}\" (score: {:.2})", symbol, score)
92            }
93            SelectionReason::CalledBy { caller, depth } => {
94                format!("CalledBy: {} (depth {})", caller, depth)
95            }
96            SelectionReason::Calls { callee, depth } => {
97                format!("Calls: {} (depth {})", callee, depth)
98            }
99            SelectionReason::SameModule { symbol } => {
100                format!("SameModule: shares context with {}", symbol)
101            }
102            SelectionReason::Explicit => "Explicit: user requested".to_string(),
103        }
104    }
105
106    /// Get the relevance weight for this reason type.
107    fn weight(&self) -> f32 {
108        match self {
109            SelectionReason::SemanticMatch { score, .. } => *score,
110            SelectionReason::CalledBy { depth, .. } => 0.8 / (*depth as f32).max(1.0),
111            SelectionReason::Calls { depth, .. } => 0.7 / (*depth as f32).max(1.0),
112            SelectionReason::SameModule { .. } => 0.5,
113            SelectionReason::Explicit => 1.0,
114        }
115    }
116}
117
118/// A selected file with relevance information.
119#[derive(Debug, Clone)]
120pub struct FileSelection {
121    /// File path (relative to project root)
122    pub path: String,
123    /// Relevance score (0.0-1.0)
124    pub relevance_score: f32,
125    /// Why this file was selected
126    pub reasons: Vec<SelectionReason>,
127    /// Token count for this file
128    pub token_count: usize,
129}
130
131impl HasTokenCount for FileSelection {
132    fn token_count(&self) -> usize {
133        self.token_count
134    }
135}
136
137impl FileSelection {
138    /// Create a new file selection.
139    fn new(path: String, reason: SelectionReason) -> Self {
140        let score = reason.weight();
141        Self {
142            path,
143            relevance_score: score,
144            reasons: vec![reason],
145            token_count: 0,
146        }
147    }
148
149    /// Add another reason for selection, updating the relevance score.
150    fn add_reason(&mut self, reason: SelectionReason) {
151        let new_weight = reason.weight();
152        // Take the maximum weight as the relevance score
153        if new_weight > self.relevance_score {
154            self.relevance_score = new_weight;
155        }
156        self.reasons.push(reason);
157    }
158}
159
160/// Result of smart context selection.
161#[derive(Debug, Clone)]
162pub struct SmartContext {
163    /// The task description used for selection
164    #[allow(dead_code)] // Part of public API
165    pub task: String,
166    /// Selected files with their relevance information
167    pub selected_files: Vec<FileSelection>,
168    /// Total token count of selected files
169    pub total_tokens: usize,
170    /// Whether selection was truncated by token limit
171    pub truncated: bool,
172    /// Number of files omitted due to token limit
173    pub omitted_count: usize,
174}
175
176/// Select files relevant to a task using semantic search and call graph analysis.
177///
178/// # Algorithm
179///
180/// 1. Embed the task description
181/// 2. Find top-N symbols matching the embedding
182/// 3. For each matched symbol:
183///    - Add the file containing the symbol
184///    - Expand call graph (callers + callees) to specified depth
185///    - Add files from expanded symbols
186/// 4. Deduplicate files, keeping highest relevance
187/// 5. Count tokens for each file
188/// 6. Sort by relevance score descending
189/// 7. Include files until token limit is reached
190pub fn smart_context(
191    db: &Database,
192    analytics: &Analytics,
193    provider: &dyn EmbeddingProvider,
194    task: &str,
195    config: SmartConfig,
196) -> Result<SmartContext> {
197    // 1. Embed the task description
198    let task_embedding = provider.embed(task)?;
199
200    smart_context_with_embedding(db, analytics, task, &task_embedding, config)
201}
202
203/// Select files relevant to a task using a pre-computed embedding.
204///
205/// This variant is useful when the embedding has been computed asynchronously
206/// (e.g., in the MCP server with OpenAI's async API) to avoid blocking the async runtime.
207///
208/// See [`smart_context`] for the full algorithm description.
209pub fn smart_context_with_embedding(
210    db: &Database,
211    analytics: &Analytics,
212    task: &str,
213    task_embedding: &Embedding,
214    config: SmartConfig,
215) -> Result<SmartContext> {
216    // 2. Semantic search for matching symbols
217    let matches = semantic_search(db, task_embedding, config.top)?;
218
219    if matches.is_empty() {
220        return Err(CtxError::NoMatches);
221    }
222
223    // 3. Build file selection map
224    let mut files: HashMap<String, FileSelection> = HashMap::new();
225
226    for result in &matches {
227        // Add the file containing the matched symbol
228        add_file(
229            &mut files,
230            &result.file_path,
231            SelectionReason::SemanticMatch {
232                symbol: result.name.clone(),
233                score: result.score,
234            },
235        );
236
237        // Expand call graph
238        expand_symbol(&mut files, analytics, result, config.depth)?;
239    }
240
241    // 4. Convert to vector and count tokens
242    let mut selections: Vec<FileSelection> = files.into_values().collect();
243
244    // Count tokens for each file
245    for selection in &mut selections {
246        selection.token_count = count_file_token_safe(&selection.path, config.encoding);
247    }
248
249    // 5. Rank files by relevance
250    rank_files(&mut selections);
251
252    // 6. Apply token limit
253    let (selected, total_tokens, omitted) = select_by_token_budget(selections, config.max_tokens);
254
255    Ok(SmartContext {
256        task: task.to_string(),
257        selected_files: selected,
258        total_tokens,
259        truncated: omitted > 0,
260        omitted_count: omitted,
261    })
262}
263
264/// Add a file to the selection map, merging reasons if already present.
265fn add_file(files: &mut HashMap<String, FileSelection>, path: &str, reason: SelectionReason) {
266    if let Some(existing) = files.get_mut(path) {
267        existing.add_reason(reason);
268    } else {
269        files.insert(
270            path.to_string(),
271            FileSelection::new(path.to_string(), reason),
272        );
273    }
274}
275
276/// Expand a symbol's call graph and add related files.
277fn expand_symbol(
278    files: &mut HashMap<String, FileSelection>,
279    analytics: &Analytics,
280    result: &SearchResult,
281    depth: i32,
282) -> Result<()> {
283    // Expand callers (who calls this symbol - impact analysis)
284    if let Ok(callers) = analytics.impact_analysis(&result.symbol_id, depth) {
285        for caller in callers {
286            add_file_from_impact(files, &caller, &result.name);
287        }
288    }
289
290    // Expand callees (what does this symbol call - call graph)
291    if let Ok(callees) = analytics.call_graph(&result.symbol_id, depth) {
292        for callee in callees {
293            add_file_from_call_graph(files, &callee, &result.name);
294        }
295    }
296
297    Ok(())
298}
299
300/// Add a file from impact analysis result.
301fn add_file_from_impact(
302    files: &mut HashMap<String, FileSelection>,
303    node: &ImpactNode,
304    callee_name: &str,
305) {
306    add_file(
307        files,
308        &node.file_path,
309        SelectionReason::CalledBy {
310            caller: node.name.clone(),
311            depth: node.distance,
312        },
313    );
314
315    // If in the same file as the callee, also add SameModule reason
316    if let Some(existing) = files.get(&node.file_path) {
317        if existing
318            .reasons
319            .iter()
320            .any(|r| matches!(r, SelectionReason::SemanticMatch { .. }))
321        {
322            // Already has a semantic match, skip SameModule
323        } else {
324            // Check if this is the same module (we'll add SameModule reason separately if needed)
325            let _ = callee_name; // Suppress unused warning
326        }
327    }
328}
329
330/// Add a file from call graph result.
331fn add_file_from_call_graph(
332    files: &mut HashMap<String, FileSelection>,
333    node: &CallGraphNode,
334    caller_name: &str,
335) {
336    add_file(
337        files,
338        &node.file_path,
339        SelectionReason::Calls {
340            callee: node.name.clone(),
341            depth: node.depth,
342        },
343    );
344
345    let _ = caller_name; // Suppress unused warning
346}
347
348/// Rank files by relevance score (descending).
349fn rank_files(files: &mut [FileSelection]) {
350    files.sort_by(|a, b| {
351        b.relevance_score
352            .partial_cmp(&a.relevance_score)
353            .unwrap_or(std::cmp::Ordering::Equal)
354    });
355}
356
357/// Count tokens in a file, returning 0 on error.
358fn count_file_token_safe(path: &str, encoding: Encoding) -> usize {
359    count_file_tokens(Path::new(path), encoding)
360        .map(|tc| tc.count)
361        .unwrap_or(0)
362}
363
364/// Format the smart context result for display.
365pub fn format_explain(result: &SmartContext) -> String {
366    let mut output = String::new();
367
368    output.push_str(&format!(
369        "Selected {} files ({} tokens):\n\n",
370        result.selected_files.len(),
371        result.total_tokens
372    ));
373
374    for (i, file) in result.selected_files.iter().enumerate() {
375        output.push_str(&format!(
376            "{}. {} ({} tokens)\n",
377            i + 1,
378            file.path,
379            file.token_count
380        ));
381
382        for reason in &file.reasons {
383            output.push_str(&format!("   - {}\n", reason.description()));
384        }
385        output.push('\n');
386    }
387
388    if result.truncated {
389        output.push_str(&format!(
390            "({} files omitted due to token limit)\n",
391            result.omitted_count
392        ));
393    }
394
395    output
396}
397
398/// Format the smart context result for dry-run mode.
399pub fn format_dry_run(result: &SmartContext) -> String {
400    let mut output = String::new();
401
402    output.push_str(&format!(
403        "Would select {} files ({} tokens):\n",
404        result.selected_files.len(),
405        result.total_tokens
406    ));
407
408    for file in &result.selected_files {
409        let primary_reason = file
410            .reasons
411            .first()
412            .map(|r| match r {
413                SelectionReason::SemanticMatch { .. } => "SemanticMatch",
414                SelectionReason::CalledBy { .. } => "CalledBy",
415                SelectionReason::Calls { .. } => "Calls",
416                SelectionReason::SameModule { .. } => "SameModule",
417                SelectionReason::Explicit => "Explicit",
418            })
419            .unwrap_or("Unknown");
420
421        output.push_str(&format!(
422            "  {} ({} tokens) - {}\n",
423            file.path, file.token_count, primary_reason
424        ));
425    }
426
427    if result.truncated {
428        output.push_str(&format!(
429            "\n({} files would be omitted due to token limit)\n",
430            result.omitted_count
431        ));
432    }
433
434    output
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn test_selection_reason_weight() {
443        let semantic = SelectionReason::SemanticMatch {
444            symbol: "test".to_string(),
445            score: 0.9,
446        };
447        assert!((semantic.weight() - 0.9).abs() < 0.001);
448
449        let called_by = SelectionReason::CalledBy {
450            caller: "main".to_string(),
451            depth: 1,
452        };
453        assert!((called_by.weight() - 0.8).abs() < 0.001);
454
455        let called_by_depth2 = SelectionReason::CalledBy {
456            caller: "main".to_string(),
457            depth: 2,
458        };
459        assert!((called_by_depth2.weight() - 0.4).abs() < 0.001);
460
461        let calls = SelectionReason::Calls {
462            callee: "helper".to_string(),
463            depth: 1,
464        };
465        assert!((calls.weight() - 0.7).abs() < 0.001);
466
467        let same_module = SelectionReason::SameModule {
468            symbol: "related".to_string(),
469        };
470        assert!((same_module.weight() - 0.5).abs() < 0.001);
471
472        let explicit = SelectionReason::Explicit;
473        assert!((explicit.weight() - 1.0).abs() < 0.001);
474    }
475
476    #[test]
477    fn test_file_selection_add_reason() {
478        let mut selection = FileSelection::new(
479            "src/main.rs".to_string(),
480            SelectionReason::Calls {
481                callee: "helper".to_string(),
482                depth: 1,
483            },
484        );
485
486        assert!((selection.relevance_score - 0.7).abs() < 0.001);
487        assert_eq!(selection.reasons.len(), 1);
488
489        // Add a higher-weight reason
490        selection.add_reason(SelectionReason::SemanticMatch {
491            symbol: "main".to_string(),
492            score: 0.95,
493        });
494
495        assert!((selection.relevance_score - 0.95).abs() < 0.001);
496        assert_eq!(selection.reasons.len(), 2);
497    }
498
499    #[test]
500    fn test_select_by_token_budget() {
501        let files = vec![
502            FileSelection {
503                path: "a.rs".to_string(),
504                relevance_score: 1.0,
505                reasons: vec![SelectionReason::Explicit],
506                token_count: 100,
507            },
508            FileSelection {
509                path: "b.rs".to_string(),
510                relevance_score: 0.8,
511                reasons: vec![SelectionReason::Explicit],
512                token_count: 200,
513            },
514            FileSelection {
515                path: "c.rs".to_string(),
516                relevance_score: 0.5,
517                reasons: vec![SelectionReason::Explicit],
518                token_count: 150,
519            },
520        ];
521
522        // All fit
523        let (selected, total, omitted) = select_by_token_budget(files.clone(), 500);
524        assert_eq!(selected.len(), 3);
525        assert_eq!(total, 450);
526        assert_eq!(omitted, 0);
527
528        // Only first two fit
529        let (selected, total, omitted) = select_by_token_budget(files.clone(), 300);
530        assert_eq!(selected.len(), 2);
531        assert_eq!(total, 300);
532        assert_eq!(omitted, 1);
533
534        // Only first fits
535        let (selected, total, omitted) = select_by_token_budget(files, 150);
536        assert_eq!(selected.len(), 1);
537        assert_eq!(total, 100);
538        assert_eq!(omitted, 2);
539    }
540
541    #[test]
542    fn test_format_dry_run() {
543        let result = SmartContext {
544            task: "add caching".to_string(),
545            selected_files: vec![
546                FileSelection {
547                    path: "src/main.rs".to_string(),
548                    relevance_score: 0.9,
549                    reasons: vec![SelectionReason::SemanticMatch {
550                        symbol: "main".to_string(),
551                        score: 0.9,
552                    }],
553                    token_count: 500,
554                },
555                FileSelection {
556                    path: "src/lib.rs".to_string(),
557                    relevance_score: 0.7,
558                    reasons: vec![SelectionReason::Calls {
559                        callee: "helper".to_string(),
560                        depth: 1,
561                    }],
562                    token_count: 300,
563                },
564            ],
565            total_tokens: 800,
566            truncated: false,
567            omitted_count: 0,
568        };
569
570        let output = format_dry_run(&result);
571        assert!(output.contains("Would select 2 files"));
572        assert!(output.contains("src/main.rs"));
573        assert!(output.contains("SemanticMatch"));
574    }
575
576    #[test]
577    fn test_smart_config_default() {
578        let config = SmartConfig::default();
579        assert_eq!(config.max_tokens, 8000);
580        assert_eq!(config.depth, 2);
581        assert_eq!(config.top, 10);
582    }
583
584    #[test]
585    fn test_add_file_merges_reasons() {
586        let mut files: HashMap<String, FileSelection> = HashMap::new();
587
588        // First add
589        add_file(
590            &mut files,
591            "src/main.rs",
592            SelectionReason::SemanticMatch {
593                symbol: "main".to_string(),
594                score: 0.9,
595            },
596        );
597        assert_eq!(files.len(), 1);
598        assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
599
600        // Second add to same file - should merge
601        add_file(
602            &mut files,
603            "src/main.rs",
604            SelectionReason::CalledBy {
605                caller: "run".to_string(),
606                depth: 1,
607            },
608        );
609        assert_eq!(files.len(), 1); // Still only 1 file
610        assert_eq!(files.get("src/main.rs").unwrap().reasons.len(), 2);
611        // Score should be max of the two
612        assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
613    }
614
615    #[test]
616    fn test_rank_files_sorts_by_relevance() {
617        let mut files = vec![
618            FileSelection {
619                path: "low.rs".to_string(),
620                relevance_score: 0.3,
621                reasons: vec![SelectionReason::Explicit],
622                token_count: 100,
623            },
624            FileSelection {
625                path: "high.rs".to_string(),
626                relevance_score: 0.9,
627                reasons: vec![SelectionReason::Explicit],
628                token_count: 100,
629            },
630            FileSelection {
631                path: "mid.rs".to_string(),
632                relevance_score: 0.5,
633                reasons: vec![SelectionReason::Explicit],
634                token_count: 100,
635            },
636        ];
637
638        rank_files(&mut files);
639
640        assert_eq!(files[0].path, "high.rs");
641        assert_eq!(files[1].path, "mid.rs");
642        assert_eq!(files[2].path, "low.rs");
643    }
644
645    #[test]
646    fn test_selection_reason_description() {
647        let semantic = SelectionReason::SemanticMatch {
648            symbol: "test".to_string(),
649            score: 0.9,
650        };
651        assert!(semantic.description().contains("SemanticMatch"));
652        assert!(semantic.description().contains("test"));
653
654        let called_by = SelectionReason::CalledBy {
655            caller: "main".to_string(),
656            depth: 2,
657        };
658        assert!(called_by.description().contains("CalledBy"));
659        assert!(called_by.description().contains("main"));
660        assert!(called_by.description().contains("2"));
661    }
662}