use std::collections::HashMap;
use std::path::Path;
use crate::analytics::{Analytics, CallGraphNode, ImpactNode};
use crate::db::Database;
use crate::embeddings::{semantic_search, Embedding, EmbeddingProvider, SearchResult};
use crate::error::{CtxError, Result};
use crate::tokens::{count_file_tokens, select_by_token_budget, Encoding, HasTokenCount};
#[derive(Debug, Clone)]
pub struct SmartConfig {
pub max_tokens: usize,
pub depth: i32,
pub top: usize,
pub encoding: Encoding,
}
impl Default for SmartConfig {
fn default() -> Self {
Self {
max_tokens: 8000,
depth: 2,
top: 10,
encoding: Encoding::default(),
}
}
}
#[derive(Debug, Clone)]
pub enum SelectionReason {
SemanticMatch {
symbol: String,
score: f32,
},
CalledBy {
caller: String,
depth: i32,
},
Calls {
callee: String,
depth: i32,
},
#[allow(dead_code)] SameModule {
symbol: String,
},
#[allow(dead_code)] Explicit,
}
impl SelectionReason {
pub fn description(&self) -> String {
match self {
SelectionReason::SemanticMatch { symbol, score } => {
format!("SemanticMatch: \"{}\" (score: {:.2})", symbol, score)
}
SelectionReason::CalledBy { caller, depth } => {
format!("CalledBy: {} (depth {})", caller, depth)
}
SelectionReason::Calls { callee, depth } => {
format!("Calls: {} (depth {})", callee, depth)
}
SelectionReason::SameModule { symbol } => {
format!("SameModule: shares context with {}", symbol)
}
SelectionReason::Explicit => "Explicit: user requested".to_string(),
}
}
fn weight(&self) -> f32 {
match self {
SelectionReason::SemanticMatch { score, .. } => *score,
SelectionReason::CalledBy { depth, .. } => 0.8 / (*depth as f32).max(1.0),
SelectionReason::Calls { depth, .. } => 0.7 / (*depth as f32).max(1.0),
SelectionReason::SameModule { .. } => 0.5,
SelectionReason::Explicit => 1.0,
}
}
}
#[derive(Debug, Clone)]
pub struct FileSelection {
pub path: String,
pub relevance_score: f32,
pub reasons: Vec<SelectionReason>,
pub token_count: usize,
}
impl HasTokenCount for FileSelection {
fn token_count(&self) -> usize {
self.token_count
}
}
impl FileSelection {
fn new(path: String, reason: SelectionReason) -> Self {
let score = reason.weight();
Self {
path,
relevance_score: score,
reasons: vec![reason],
token_count: 0,
}
}
fn add_reason(&mut self, reason: SelectionReason) {
let new_weight = reason.weight();
if new_weight > self.relevance_score {
self.relevance_score = new_weight;
}
self.reasons.push(reason);
}
}
#[derive(Debug, Clone)]
pub struct SmartContext {
#[allow(dead_code)] pub task: String,
pub selected_files: Vec<FileSelection>,
pub total_tokens: usize,
pub truncated: bool,
pub omitted_count: usize,
}
pub fn smart_context(
db: &Database,
analytics: &Analytics,
provider: &dyn EmbeddingProvider,
task: &str,
config: SmartConfig,
) -> Result<SmartContext> {
let task_embedding = provider.embed(task)?;
smart_context_with_embedding(db, analytics, task, &task_embedding, config)
}
pub fn smart_context_with_embedding(
db: &Database,
analytics: &Analytics,
task: &str,
task_embedding: &Embedding,
config: SmartConfig,
) -> Result<SmartContext> {
let matches = semantic_search(db, task_embedding, config.top)?;
if matches.is_empty() {
return Err(CtxError::NoMatches);
}
let mut files: HashMap<String, FileSelection> = HashMap::new();
for result in &matches {
add_file(
&mut files,
&result.file_path,
SelectionReason::SemanticMatch {
symbol: result.name.clone(),
score: result.score,
},
);
expand_symbol(&mut files, analytics, result, config.depth)?;
}
let mut selections: Vec<FileSelection> = files.into_values().collect();
for selection in &mut selections {
selection.token_count = count_file_token_safe(&selection.path, config.encoding);
}
rank_files(&mut selections);
let (selected, total_tokens, omitted) = select_by_token_budget(selections, config.max_tokens);
Ok(SmartContext {
task: task.to_string(),
selected_files: selected,
total_tokens,
truncated: omitted > 0,
omitted_count: omitted,
})
}
fn add_file(files: &mut HashMap<String, FileSelection>, path: &str, reason: SelectionReason) {
if let Some(existing) = files.get_mut(path) {
existing.add_reason(reason);
} else {
files.insert(
path.to_string(),
FileSelection::new(path.to_string(), reason),
);
}
}
fn expand_symbol(
files: &mut HashMap<String, FileSelection>,
analytics: &Analytics,
result: &SearchResult,
depth: i32,
) -> Result<()> {
if let Ok(callers) = analytics.impact_analysis(&result.symbol_id, depth) {
for caller in callers {
add_file_from_impact(files, &caller, &result.name);
}
}
if let Ok(callees) = analytics.call_graph(&result.symbol_id, depth) {
for callee in callees {
add_file_from_call_graph(files, &callee, &result.name);
}
}
Ok(())
}
fn add_file_from_impact(
files: &mut HashMap<String, FileSelection>,
node: &ImpactNode,
callee_name: &str,
) {
add_file(
files,
&node.file_path,
SelectionReason::CalledBy {
caller: node.name.clone(),
depth: node.distance,
},
);
if let Some(existing) = files.get(&node.file_path) {
if existing
.reasons
.iter()
.any(|r| matches!(r, SelectionReason::SemanticMatch { .. }))
{
} else {
let _ = callee_name; }
}
}
fn add_file_from_call_graph(
files: &mut HashMap<String, FileSelection>,
node: &CallGraphNode,
caller_name: &str,
) {
add_file(
files,
&node.file_path,
SelectionReason::Calls {
callee: node.name.clone(),
depth: node.depth,
},
);
let _ = caller_name; }
fn rank_files(files: &mut [FileSelection]) {
files.sort_by(|a, b| {
b.relevance_score
.partial_cmp(&a.relevance_score)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
fn count_file_token_safe(path: &str, encoding: Encoding) -> usize {
count_file_tokens(Path::new(path), encoding)
.map(|tc| tc.count)
.unwrap_or(0)
}
pub fn format_explain(result: &SmartContext) -> String {
let mut output = String::new();
output.push_str(&format!(
"Selected {} files ({} tokens):\n\n",
result.selected_files.len(),
result.total_tokens
));
for (i, file) in result.selected_files.iter().enumerate() {
output.push_str(&format!(
"{}. {} ({} tokens)\n",
i + 1,
file.path,
file.token_count
));
for reason in &file.reasons {
output.push_str(&format!(" - {}\n", reason.description()));
}
output.push('\n');
}
if result.truncated {
output.push_str(&format!(
"({} files omitted due to token limit)\n",
result.omitted_count
));
}
output
}
pub fn format_dry_run(result: &SmartContext) -> String {
let mut output = String::new();
output.push_str(&format!(
"Would select {} files ({} tokens):\n",
result.selected_files.len(),
result.total_tokens
));
for file in &result.selected_files {
let primary_reason = file
.reasons
.first()
.map(|r| match r {
SelectionReason::SemanticMatch { .. } => "SemanticMatch",
SelectionReason::CalledBy { .. } => "CalledBy",
SelectionReason::Calls { .. } => "Calls",
SelectionReason::SameModule { .. } => "SameModule",
SelectionReason::Explicit => "Explicit",
})
.unwrap_or("Unknown");
output.push_str(&format!(
" {} ({} tokens) - {}\n",
file.path, file.token_count, primary_reason
));
}
if result.truncated {
output.push_str(&format!(
"\n({} files would be omitted due to token limit)\n",
result.omitted_count
));
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_selection_reason_weight() {
let semantic = SelectionReason::SemanticMatch {
symbol: "test".to_string(),
score: 0.9,
};
assert!((semantic.weight() - 0.9).abs() < 0.001);
let called_by = SelectionReason::CalledBy {
caller: "main".to_string(),
depth: 1,
};
assert!((called_by.weight() - 0.8).abs() < 0.001);
let called_by_depth2 = SelectionReason::CalledBy {
caller: "main".to_string(),
depth: 2,
};
assert!((called_by_depth2.weight() - 0.4).abs() < 0.001);
let calls = SelectionReason::Calls {
callee: "helper".to_string(),
depth: 1,
};
assert!((calls.weight() - 0.7).abs() < 0.001);
let same_module = SelectionReason::SameModule {
symbol: "related".to_string(),
};
assert!((same_module.weight() - 0.5).abs() < 0.001);
let explicit = SelectionReason::Explicit;
assert!((explicit.weight() - 1.0).abs() < 0.001);
}
#[test]
fn test_file_selection_add_reason() {
let mut selection = FileSelection::new(
"src/main.rs".to_string(),
SelectionReason::Calls {
callee: "helper".to_string(),
depth: 1,
},
);
assert!((selection.relevance_score - 0.7).abs() < 0.001);
assert_eq!(selection.reasons.len(), 1);
selection.add_reason(SelectionReason::SemanticMatch {
symbol: "main".to_string(),
score: 0.95,
});
assert!((selection.relevance_score - 0.95).abs() < 0.001);
assert_eq!(selection.reasons.len(), 2);
}
#[test]
fn test_select_by_token_budget() {
let files = vec![
FileSelection {
path: "a.rs".to_string(),
relevance_score: 1.0,
reasons: vec![SelectionReason::Explicit],
token_count: 100,
},
FileSelection {
path: "b.rs".to_string(),
relevance_score: 0.8,
reasons: vec![SelectionReason::Explicit],
token_count: 200,
},
FileSelection {
path: "c.rs".to_string(),
relevance_score: 0.5,
reasons: vec![SelectionReason::Explicit],
token_count: 150,
},
];
let (selected, total, omitted) = select_by_token_budget(files.clone(), 500);
assert_eq!(selected.len(), 3);
assert_eq!(total, 450);
assert_eq!(omitted, 0);
let (selected, total, omitted) = select_by_token_budget(files.clone(), 300);
assert_eq!(selected.len(), 2);
assert_eq!(total, 300);
assert_eq!(omitted, 1);
let (selected, total, omitted) = select_by_token_budget(files, 150);
assert_eq!(selected.len(), 1);
assert_eq!(total, 100);
assert_eq!(omitted, 2);
}
#[test]
fn test_format_dry_run() {
let result = SmartContext {
task: "add caching".to_string(),
selected_files: vec![
FileSelection {
path: "src/main.rs".to_string(),
relevance_score: 0.9,
reasons: vec![SelectionReason::SemanticMatch {
symbol: "main".to_string(),
score: 0.9,
}],
token_count: 500,
},
FileSelection {
path: "src/lib.rs".to_string(),
relevance_score: 0.7,
reasons: vec![SelectionReason::Calls {
callee: "helper".to_string(),
depth: 1,
}],
token_count: 300,
},
],
total_tokens: 800,
truncated: false,
omitted_count: 0,
};
let output = format_dry_run(&result);
assert!(output.contains("Would select 2 files"));
assert!(output.contains("src/main.rs"));
assert!(output.contains("SemanticMatch"));
}
#[test]
fn test_smart_config_default() {
let config = SmartConfig::default();
assert_eq!(config.max_tokens, 8000);
assert_eq!(config.depth, 2);
assert_eq!(config.top, 10);
}
#[test]
fn test_add_file_merges_reasons() {
let mut files: HashMap<String, FileSelection> = HashMap::new();
add_file(
&mut files,
"src/main.rs",
SelectionReason::SemanticMatch {
symbol: "main".to_string(),
score: 0.9,
},
);
assert_eq!(files.len(), 1);
assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
add_file(
&mut files,
"src/main.rs",
SelectionReason::CalledBy {
caller: "run".to_string(),
depth: 1,
},
);
assert_eq!(files.len(), 1); assert_eq!(files.get("src/main.rs").unwrap().reasons.len(), 2);
assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
}
#[test]
fn test_rank_files_sorts_by_relevance() {
let mut files = vec![
FileSelection {
path: "low.rs".to_string(),
relevance_score: 0.3,
reasons: vec![SelectionReason::Explicit],
token_count: 100,
},
FileSelection {
path: "high.rs".to_string(),
relevance_score: 0.9,
reasons: vec![SelectionReason::Explicit],
token_count: 100,
},
FileSelection {
path: "mid.rs".to_string(),
relevance_score: 0.5,
reasons: vec![SelectionReason::Explicit],
token_count: 100,
},
];
rank_files(&mut files);
assert_eq!(files[0].path, "high.rs");
assert_eq!(files[1].path, "mid.rs");
assert_eq!(files[2].path, "low.rs");
}
#[test]
fn test_selection_reason_description() {
let semantic = SelectionReason::SemanticMatch {
symbol: "test".to_string(),
score: 0.9,
};
assert!(semantic.description().contains("SemanticMatch"));
assert!(semantic.description().contains("test"));
let called_by = SelectionReason::CalledBy {
caller: "main".to_string(),
depth: 2,
};
assert!(called_by.description().contains("CalledBy"));
assert!(called_by.description().contains("main"));
assert!(called_by.description().contains("2"));
}
}